From 83352c45711e4512c29ef4cf2a43fcce86c29990 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Thu, 30 Jul 2026 01:07:25 +0800 Subject: [PATCH] feat!: add source-clean live buffer layout Replace the physical ekp-region renderer with ekp-buffer text-property projection backed by shared semantic layout plans and stable live-edit transactions. Optimize the C-backed resize hot path, and add deterministic performance, ERT, GUI, fuzz, release, and documentation coverage for task017 through task032. BREAKING CHANGE: require ekp-buffer instead of ekp-region and rename module-owned ekp-region-* settings to ekp-buffer-*. --- .github/workflows/ci.yml | 10 +- .phrase/docs/CHANGE.md | 41 + .phrase/docs/ISSUES.md | 10 + .../change_buffer_module_naming_20260728.md | 61 + .../change_c_resize_latency_20260729.md | 39 + ...nge_editing_frontier_stability_20260729.md | 74 + .../change_logical_text_api_issue_20260728.md | 28 + .../change_semantic_live_prefix_20260729.md | 76 + ...change_stable_live_transaction_20260729.md | 56 + ...property_layout_implementation_20260729.md | 286 ++ ..._text_property_layout_research_20260729.md | 56 + .../issue_c_resize_latency_20260729.md | 55 + ...issue_live_editing_interaction_20260729.md | 119 + .../issue_logical_text_api_20260728.md | 132 + ...sue_narrow_live_append_latency_20260729.md | 59 + .../issue_native_soft_wrap_20260729.md | 51 + .../issue_natural_live_editing_20260729.md | 54 + ...nt_motion_projection_stability_20260729.md | 82 + .../issue_semantic_live_prefix_20260729.md | 97 + ...e_stable_live_edit_transaction_20260729.md | 72 + .../plan_buffer_module_naming_20260728.md | 87 + ...an_text_property_layout_engine_20260729.md | 283 ++ ...ec_text_property_layout_engine_20260729.md | 192 ++ .../task_repository_audit_20260728.md | 467 ++++ ...ech-refer_text_property_layout_20260729.md | 408 +++ CHANGELOG.md | 93 +- CONTRIBUTING.md | 14 +- DEVELOPER.md | 161 +- DEVELOPER_ZH.md | 134 +- Docs/REPOSITORY_AUDIT_20260728.md | 3 + ekp-buffer.el | 2011 ++++++++++++++ ekp-region.el | 1079 -------- ekp.el | 333 ++- postmortem/20260728-buffer-module-naming.md | 101 + postmortem/20260729-active-line-edge-state.md | 60 + .../20260729-editing-frontier-not-point.md | 104 + .../20260729-narrow-live-append-replanning.md | 57 + ...0260729-native-progressive-live-editing.md | 128 + .../20260729-native-soft-wrap-precondition.md | 90 + .../20260729-stable-live-transaction.md | 117 + .../20260729-text-property-live-layout.md | 163 ++ .../20260729-whole-hard-line-live-prefix.md | 147 ++ .../20260730-c-resize-hot-path-ownership.md | 53 + readme.md | 125 +- readme_zh.md | 99 +- tests/ekp-buffer-live-bench.el | 239 ++ tests/ekp-buffer-tests.el | 2310 +++++++++++++++++ tests/ekp-c-resize-evaluator.el | 344 +++ tests/ekp-c-tests.el | 4 + tests/ekp-gui-verify.el | 1062 +++++++- tests/ekp-region-tests.el | 831 ------ tests/ekp-showcase.el | 2 +- tests/ekp-tests.el | 86 + tests/run-c-resize-evaluator.sh | 72 + tests/run-tests-isolated.sh | 4 +- tests/run-tests.sh | 4 +- 56 files changed, 10704 insertions(+), 2221 deletions(-) create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_buffer_module_naming_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_c_resize_latency_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_editing_frontier_stability_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_logical_text_api_issue_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_semantic_live_prefix_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_stable_live_transaction_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_research_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_live_editing_interaction_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_narrow_live_append_latency_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_native_soft_wrap_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_natural_live_editing_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_point_motion_projection_stability_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_semantic_live_prefix_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_stable_live_edit_transaction_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_buffer_module_naming_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_text_property_layout_engine_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/spec_text_property_layout_engine_20260729.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/tech-refer_text_property_layout_20260729.md create mode 100644 ekp-buffer.el delete mode 100644 ekp-region.el create mode 100644 postmortem/20260728-buffer-module-naming.md create mode 100644 postmortem/20260729-active-line-edge-state.md create mode 100644 postmortem/20260729-editing-frontier-not-point.md create mode 100644 postmortem/20260729-narrow-live-append-replanning.md create mode 100644 postmortem/20260729-native-progressive-live-editing.md create mode 100644 postmortem/20260729-native-soft-wrap-precondition.md create mode 100644 postmortem/20260729-stable-live-transaction.md create mode 100644 postmortem/20260729-text-property-live-layout.md create mode 100644 postmortem/20260729-whole-hard-line-live-prefix.md create mode 100644 postmortem/20260730-c-resize-hot-path-ownership.md create mode 100644 tests/ekp-buffer-live-bench.el create mode 100644 tests/ekp-buffer-tests.el create mode 100644 tests/ekp-c-resize-evaluator.el delete mode 100644 tests/ekp-region-tests.el create mode 100755 tests/run-c-resize-evaluator.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b76c796..7fbb9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: | emacs -Q --batch -L . \ --eval '(setq byte-compile-error-on-warn t)' \ - -f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el + -f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el ekp-buffer.el - name: Run ERT suite (C-module tests auto-skip) run: tests/run-tests.sh emacs - name: Run ERT suite in permuted order @@ -62,12 +62,12 @@ jobs: --eval "(require 'package-lint)" \ --eval "(setq package-lint-main-file \"ekp.el\")" \ -f package-lint-batch-and-exit \ - ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el + ekp.el ekp-utils.el ekp-hyphen.el ekp-buffer.el - name: checkdoc run: | emacs -Q --batch \ --eval "(setq sentence-end-double-space t)" \ - --eval "(dolist (f '(\"ekp.el\" \"ekp-utils.el\" \"ekp-hyphen.el\" \"ekp-region.el\")) + --eval "(dolist (f '(\"ekp.el\" \"ekp-utils.el\" \"ekp-hyphen.el\" \"ekp-buffer.el\")) (checkdoc-file f))" \ 2>&1 | tee checkdoc.log test ! -s checkdoc.log @@ -162,13 +162,13 @@ jobs: emacs -Q --batch -L . --eval "(setq byte-compile-error-on-warn t)" -f batch-byte-compile - ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el + ekp.el ekp-utils.el ekp-hyphen.el ekp-buffer.el - name: Run ERT suite (C-module tests auto-skip) shell: pwsh run: > emacs -Q --batch -L . -L tests -l tests/ekp-tests.el - -l tests/ekp-region-tests.el + -l tests/ekp-buffer-tests.el -l tests/ekp-gui-tests.el -l tests/ekp-c-tests.el -f ert-run-tests-batch-and-exit diff --git a/.phrase/docs/CHANGE.md b/.phrase/docs/CHANGE.md index 7360a42..6fb8d5d 100644 --- a/.phrase/docs/CHANGE.md +++ b/.phrase/docs/CHANGE.md @@ -37,6 +37,47 @@ - 2026-07-28 make the same-string paragraph fast path property-sensitive (`task016`): `.phrase/phases/phase-kp-overhaul-20260726/change_property_sensitive_fast_path_20260728.md` +- 2026-07-28 rename editor integration around its buffer owner (`task017`): + `.phrase/phases/phase-kp-overhaul-20260726/change_buffer_module_naming_20260728.md` +- 2026-07-28 record the logical-text API boundary (`issue011`, no + implementation): + `.phrase/phases/phase-kp-overhaul-20260726/change_logical_text_api_issue_20260728.md` +- 2026-07-29 record the text-property-only layout and non-polluting hyphen + feasibility (`task018`, `issue011` remains open): + `.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_research_20260729.md` +- 2026-07-29 implement and close the shared KP layout plan, source-clean + static projection, seamless live flow, and repository quality gate + (`task019`–`task022`; `issue011` awaits user-visible acceptance): + `.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md` +- 2026-07-29 fix and verify inactive-mark selection and same-turn + edge-whitespace visibility (`task023`–`task024`; `issue012` and + `issue013` await user-visible acceptance): + `.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md` +- 2026-07-29 replace partial live KP with the approved native progressive + editing model (`task025`–`task026` complete; `issue014` awaits + user-visible confirmation): + `.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md` +- 2026-07-29 implement and verify the native soft-wrap lifecycle for narrow + split windows (`task027` complete; `issue015` awaits user-visible + confirmation): + `.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md` +- 2026-07-29 implement and developer-verify semantic hard-line prefix live + editing (`task028`; `issue016` awaits user-visible confirmation): + `.phrase/phases/phase-kp-overhaul-20260726/change_semantic_live_prefix_20260729.md` +- 2026-07-29 make point-only motion projection-stable and move the live + boundary to a source-edit-owned frontier (`task029` complete; + `issue016` and `issue017` await user-visible confirmation; separate + narrow-width append debt is `issue018`/`task030`): + `.phrase/phases/phase-kp-overhaul-20260726/change_editing_frontier_stability_20260729.md` +- 2026-07-29 implement and developer-verify the stable live-layout + transaction that supersedes per-keystroke frontier replanning + (`task031` complete; `issue019` awaits user confirmation; `task030` + is unblocked for structural-commit profiling): + `.phrase/phases/phase-kp-overhaul-20260726/change_stable_live_transaction_20260729.md` +- 2026-07-30 reduce core and complete C-backed resize p95 to + 27.687/27.487 ms with exact layout parity (`task032` complete; + `issue020` awaits user confirmation): + `.phrase/phases/phase-kp-overhaul-20260726/change_c_resize_latency_20260729.md` ## phase-doc-improvement-20260125 - See `.phrase/phases/phase-doc-improvement-20260125/change_log.md` diff --git a/.phrase/docs/ISSUES.md b/.phrase/docs/ISSUES.md index cb59ac4..bef4c79 100644 --- a/.phrase/docs/ISSUES.md +++ b/.phrase/docs/ISSUES.md @@ -10,3 +10,13 @@ - issue008 [x] [Hot-loop allocation and nil caching need benchmark gates.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) - issue009 [x] [Release and documentation governance are not closed.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) - issue010 [x] [Same-string property mutation bypasses paragraph cache identity.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue011 [ ] [Direct Elisp buffer APIs observe physical layout text.](../phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md) +- issue012 [ ] [Reprojection activates an inactive mark and selects text.](../phases/phase-kp-overhaul-20260726/issue_live_editing_interaction_20260729.md#issue012--reprojection-activates-an-inactive-mark) +- issue013 [ ] [Active-line edge whitespace is hidden until another glyph arrives.](../phases/phase-kp-overhaul-20260726/issue_live_editing_interaction_20260729.md#issue013--active-line-edge-whitespace-is-hidden) +- issue014 [ ] [Near-edge live editing publishes KP breaks before the paragraph is complete.](../phases/phase-kp-overhaul-20260726/issue_natural_live_editing_20260729.md) +- issue015 [ ] [Narrow side-by-side windows truncate instead of soft-wrapping live text.](../phases/phase-kp-overhaul-20260726/issue_native_soft_wrap_20260729.md) +- issue016 [ ] [Live editing freezes native rows instead of projecting a semantic hard-line prefix.](../phases/phase-kp-overhaul-20260726/issue_semantic_live_prefix_20260729.md) +- issue017 [ ] [Point-only motion changes an already published live projection.](../phases/phase-kp-overhaul-20260726/issue_point_motion_projection_stability_20260729.md) +- issue018 [ ] [Unique live appends miss the frame budget at very narrow widths.](../phases/phase-kp-overhaul-20260726/issue_narrow_live_append_latency_20260729.md) +- issue019 [ ] [Per-edit frontier replanning destabilizes live projection and cannot restore reversible edits exactly.](../phases/phase-kp-overhaul-20260726/issue_stable_live_edit_transaction_20260729.md) +- issue020 [ ] [C-backed resize reflow still takes roughly 60–70 ms.](../phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md) diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_buffer_module_naming_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_buffer_module_naming_20260728.md new file mode 100644 index 0000000..2f295f3 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_buffer_module_naming_20260728.md @@ -0,0 +1,61 @@ +# Change Log: Buffer Module Naming 2026-07-28 + +## task017 + +- **Delete/Add** — `ekp-region.el` → `ekp-buffer.el` + - Renamed the editor integration file and provided feature around the + buffer lifecycle owner. + - Renamed module configuration and private symbols from `ekp-region*` to + `ekp-buffer*`. + - Attached the buffer customization subgroup to the top-level `ekp` + group and made the protrusion-reserve helper explicitly private. + - Kept public commands whose names correctly describe region, buffer, or + mode behavior. + +- **Delete/Add** — `tests/ekp-region-tests.el` → + `tests/ekp-buffer-tests.el` + - Renamed the test feature, fixtures, and ERT names. + - Updated default, random-order, isolated, GUI, and showcase loaders. + +- **Modify** — CI, contribution, public/developer/audit documentation + - Updated compilation, lint, checkdoc, test, and Windows CI paths. + - Replaced the documented require/configuration surface and file maps. + - Added the breaking migration to `CHANGELOG.md`. + - Recorded the ownership decision in + `postmortem/20260728-buffer-module-naming.md`. + +## Cleanup Review + +- Naming mismatch: resolved at the owning module/feature/configuration/test + boundary. +- Dead code, duplication, error handling, dependency, and UI: unchanged and + outside this pass. +- Fallback findings: only the existing selected-window width fallback, + classified as a grounded undisplayed-buffer boundary. +- No masking fallback, swallowed error, compatibility shim, new dependency, + new abstraction, or unrelated refactor was introduced. + +## Validation + +- Pre-change behavior lock: ERT 130/130. +- Focused buffer ERT after rename: 44/44. +- Default and seeded-permuted ERT: 130/130 each. +- Fresh-process isolation: every one of 130 selected ERT tests passed. +- C/Elisp property fuzz: 300/300. +- Warning-as-error production byte compilation: pass. +- Checkdoc and pinned package-lint: pass. +- Release invariants, shell syntax, diff check, and active-surface stale-name + scan: pass. +- Mechanical runtime/test equivalence against the approved rename table: + pass. + +## Behavior and Risk + +- Layout, serialization, search, copy, undo, and automatic reflow behavior + are unchanged. +- This is an intentional source-level breaking rename: users must require + `ekp-buffer` and rename any `ekp-region-*` settings. +- No saved-file format, dependency, Emacs baseline, C ABI, or release + artifact changed. +- Generated `.elc` verification artifacts were removed after compilation; + they can be regenerated from the source. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_c_resize_latency_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/change_c_resize_latency_20260729.md new file mode 100644 index 0000000..6eb85bc --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_c_resize_latency_20260729.md @@ -0,0 +1,39 @@ +# Change: C Resize Latency 2026-07-29 + +## 2026-07-30 — Complete task032 core and resize optimization + +- **Modify** `ekp.el`: resolve a paragraph once per layout plan, reuse its + prepared DP data, memoize width-independent natural gap geometry, and omit + true zero-source/zero-target projection gaps. +- **Modify** `ekp-buffer.el`: publish owned properties in one mutation, + consume prepared gap geometry without projection-time measurement, and + keep the active paragraph out of the static resize pass before installing + its live prefix. +- **Add** focused RED/GREEN regressions in `tests/ekp-tests.el` and + `tests/ekp-buffer-tests.el`, plus the frozen dual-path evaluator in + `tests/ekp-c-resize-evaluator.el` and + `tests/run-c-resize-evaluator.sh`. +- **Performance:** four interleaved rounds reduce core p95 from 42.006 ms to + 27.687 ms (34.09%) and complete resize p95 from 46.611 ms to 27.487 ms + (41.03%); both p50 gains also exceed 20%. +- **Correctness:** frozen-C, candidate-C, and Elisp projection hashes match. + Normal, random-order, and isolated ERT gates pass; 300 fuzz cases, static + checks, release checks, and reviewed temporal GUI evidence pass. +- **Risk:** external window-system allocation can trigger an Emacs GC pause + at the resize callback boundary. Total, GC, and EKP mutator time remain + separately observable; no debounce or global GC behavior changed. + +## 2026-07-29 — Plan issue020 and task032 + +- **Add** `issue020` and `task032` for the reported 60–70 ms C-backed + resize/reflow latency. +- **Add** the `c-resize-latency` performance-goal contract before + optimization: paired p50/p95 improvement of at least 20%, candidate p95 + at or below 50 ms, exact frozen-C and Elisp parity, and complete + regression gates. +- **Decision:** measure complete reflow, plan construction, Emacs/C + marshalling, C DP, and projection publication separately before choosing + the implementation layer. +- **Risk:** a whole-reflow stopwatch can misattribute Elisp measurement or + property publication to the C algorithm. No runtime change is included in + this planning entry. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_editing_frontier_stability_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/change_editing_frontier_stability_20260729.md new file mode 100644 index 0000000..2694ec7 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_editing_frontier_stability_20260729.md @@ -0,0 +1,74 @@ +# Change: Editing-Frontier Stability 2026-07-29 + +## 2026-07-29 — Implement and developer-verify task029 + +- **Modify** `ekp-buffer.el`. + - Adds a source-relative `frontier` marker to live state. + - Derives the active semantic line from the latest real edit, relocates + it in `after-change`, and preserves it through width/font/context + reflow. + - Deletes point-driven live-boundary publication. Point motion inside + the active hard line is display read-only; leaving the hard line keeps + the existing static completion transition. + - Keeps core DP semantics, `ekp.el`, C ABI, schema, plan contract, source + characters, and no-overlay ownership unchanged. +- **Modify** `tests/ekp-buffer-tests.el`, + `tests/ekp-gui-verify.el`, and + `tests/ekp-buffer-live-bench.el`. + - Locks exact property/state/source/undo identity and zero + plan/cache/property writes across backward/forward point motion. + - Proves reflow and deferred IME completion preserve the source-edit + frontier even when point moves elsewhere. + - Updates GUI and benchmark adapters to assert projection, plan, + generation, cache, active-index, and frontier stability. +- **Modify** bilingual user/developer docs, spec, plan, technical + reference, issues, changelog, and superseding postmortem to use the same + source-edit-frontier vocabulary. +- **Verification:** focused regressions RED before implementation and + GREEN afterward; independent focused ERT 10/10; buffer ERT 93/93; + default, seed-`20260729`, and isolated ERT 182/182; C/Elisp fuzz 300/300; + warning-as-error compilation, checkdoc, pinned package-lint, release, + dictionary, pinned-source, no-overlay, no-stale-symbol, and diff gates + pass. Point-motion p99 is 0.033 ms on C and 0.037 ms in the independent + Elisp review, both with zero planner/cache calls. +- **GUI evidence:** reviewed run + `/private/tmp/ekp-frontier-live-v3-66WYRW` contains 39 manifest lines and + a 26.6-second recording. All checkpoints and assertions pass, no black + segment is detected, and the final evidence verdict is PASS. +- **Review:** independent architecture review is CLEAR. Independent code + review reports zero blockers; the one residual performance concern is + separately owned by `issue018`/`task030`. +- **Status:** task029 developer gate complete. `issue016` and `issue017` + remain open until the user personally confirms the visible editing + experience. + +## 2026-07-29 — Record the independent append-performance debt + +- **Add** `issue018` and `task030` after the current checked-in 80-pixel + benchmark failed to reproduce task028's 6.399 ms append p99. +- **Add** `postmortem/20260729-narrow-live-append-replanning.md` to keep + unique-source-state planning cost separate from task029's point-motion + correctness fix. +- **Evidence:** repeated GC-excluded C-backend runs record roughly + 33–85 ms append p99, 291 plan calls, and zero cache hits. The latest run + recorded 84.526 ms. In the same run, point motion recorded 0.033 ms p99 + with zero plan/cache calls. +- **Behavior/Risk:** documentation only. No performance shortcut or core + DP change is included in task029. + +## 2026-07-29 — Plan task029 + +- **Add** `issue017`: point-only motion currently mutates an already + published semantic live prefix. +- **Add** `task029`: move live-boundary ownership from transient point to + the latest real source edit, while preserving whole-hard-line planning. +- **Modify** the current spec, plan, technical reference, and `issue016` + expectation so point is not a plan or projection invalidation input. +- **Add** `postmortem/20260729-editing-frontier-not-point.md` as the + superseding decision for the point-driven portion of `task028`. +- **Validation planned:** focused RED/GREEN public-hook ERT, complete + buffer/default/random/isolated suites, fuzz/static gates, live + benchmark, GUI dynamic verification, full diff review, and independent + code/architecture review. +- **Status:** Planning complete; superseded by the implementation record + above. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_logical_text_api_issue_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_logical_text_api_issue_20260728.md new file mode 100644 index 0000000..2ff46c3 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_logical_text_api_issue_20260728.md @@ -0,0 +1,28 @@ +# Change Log: Logical Text API Issue 2026-07-28 + +## issue011 + +- **Add** — + `.phrase/phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md` + - Recorded that direct Elisp buffer APIs observe EKP's physical layout + representation. + - Captured the verified physical-vs-logical extraction evidence, root + cause, required outcome, and unresolved architecture questions. + - Explicitly deferred implementation and avoided selecting a design or + creating an execution task. + +- **Modify** — `.phrase/docs/ISSUES.md`, `.phrase/docs/CHANGE.md` + - Added the open issue and linked this documentation-only change. + +## Behavior and Risk + +- Runtime code and behavior are unchanged. +- No implementation plan, compatibility promise, or solution architecture + was approved. +- The issue remains open until the user chooses to resume design work. + +## Validation + +- Confirmed `issue011` is the next unused global issue ID. +- Confirmed the issue index link resolves to the phase detail. +- `git diff --check` passes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_semantic_live_prefix_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/change_semantic_live_prefix_20260729.md new file mode 100644 index 0000000..dfe44ba --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_semantic_live_prefix_20260729.md @@ -0,0 +1,76 @@ +# Change Log: Semantic Live Prefix 2026-07-29 + +## 2026-07-29 — Implement and verify task028 + +- **Modify** `ekp-buffer.el`. + - Replaces native-row commitment with full-hard-line + `ekp-layout-plan` consumption and point-line semantic prefix + projection. + - Keeps the point-containing line and suffix natural; previous semantic + lines own their break whitespace and may reflow together. + - Adds a 16-entry buffer-local plan LRU, same-line point-motion fast + path, common-prefix differential publication, transactional rollback, + theme-enable/theme-disable/frame-font context invalidation, and + fail-closed oversized/conflict handling. + - Leaves `ekp.el`, core DP semantics, C ABI, DP schema, and plan records + unchanged for this task. +- **Modify** `tests/ekp-buffer-tests.el`, + `tests/ekp-gui-verify.el`, and **Add** + `tests/ekp-buffer-live-bench.el`. + - Covers public editing commands, point boundaries, earlier-break + revision, cache/history reuse, zero-write stable plans, whitespace and + hyphen ownership, IME, failure rollback, resize, narrowing, lifecycle, + exact source, and zero-overlay invariants. + - Default and seed-`20260729` ERT pass 181/181; all 181 tests pass in + independent Emacs processes; C/Elisp fuzz passes 300/300. + - Main dynamic GUI evidence is retained at + `/private/tmp/ekp-semantic-live-v4-vFZTkr`; split-window evidence is at + `/private/tmp/ekp-semantic-split-v3-uPwuOi`. Both reviewed temporal + reports return PASS with exact source, zero overlays, and `hscroll=0`. + - C-backend append p99 is 6.399 ms with GC excluded. Default raw p99 is + 50.212 ms when samples include approximately 45 ms GC pauses; this + residual risk is recorded instead of changing global GC behavior. + - **Subsequent audit:** the 6.399 ms result is not reproducible with the + current checked-in 80-pixel benchmark. Repeated GC-excluded task029 + runs measure roughly 33–85 ms p99 with 291 unique plan misses. + `issue018`/`task030` now track that separate performance debt; this + historical entry is retained to show what the task028 run reported. +- **Modify** bilingual user/developer docs, `CHANGELOG.md`, `issue016`, + plan/technical records, and the superseding postmortem to match the + implemented semantic-prefix model. +- **Behavior/Risk:** The active edit suffix is now native while all earlier + semantic lines are one jointly revisable KP prefix. Automated and GUI + developer gates pass. Independent code review returns APPROVE and + independent architecture review returns CLEAR. `task028` is complete; + `issue016` remains open until the user personally confirms the visible + editing experience. + +## 2026-07-29 — Plan task028 + +- **Add** `issue_semantic_live_prefix_20260729.md`. + - Records `issue016`: live editing currently freezes completed native + rows instead of repeatedly consuming the full hard-line KP plan and + projecting only the semantic prefix before point. +- **Modify** `task_repository_audit_20260728.md`. + - Adds `task028` as the next atomic implementation task with red tests, + performance checks, GUI dynamic verification, and user-confirmation + closure rules. +- **Modify** `spec_text_property_layout_engine_20260729.md`. + - Replaces the native-row live contract with the semantic hard-line + prefix contract: full hard-line plan input, point-line boundary, + prefix-only projection, natural point line and suffix, and no core + DP/C ABI changes. +- **Modify** `plan_text_property_layout_engine_20260729.md`. + - Adds milestone M7 for replacing native-row commitment with semantic + prefix projection while preserving the completed-paragraph DP owner. +- **Modify** `tech-refer_text_property_layout_20260729.md`. + - Supersedes the native progressive row model for live editing and + documents the buffer-local plan cache, line-signature diffing, and + fail-closed ownership rules. +- **Add** `postmortem/20260729-whole-hard-line-live-prefix.md`. + - Records why the native-row model was wrong for global KP alignment and + why the correction belongs in `ekp-buffer`, not in core DP. +- **Behavior/Risk:** Documentation and planning only. Runtime behavior is + unchanged. `issue015` remains open exactly as before pending user-visible + confirmation; `issue016` remains open until implementation and user + confirmation. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_stable_live_transaction_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/change_stable_live_transaction_20260729.md new file mode 100644 index 0000000..0a07106 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_stable_live_transaction_20260729.md @@ -0,0 +1,56 @@ +# Change: Stable Live Edit Transaction 2026-07-29 + +## 2026-07-29 — Implement and verify task031 + +- **Modify** `ekp-buffer.el`: replace frontier-owned immediate publication + with committed live state plus one baseline-snapshot edit transaction; + naturalize only the owning dirty span range; restore reversible edits + exactly; and publish only at native-row or structural commit events. +- **Modify** `ekp-buffer.el`: keep active live spans out of static lazy-reflow + chunks and recognize both forward and backward native-row crossings. +- **Modify** `tests/ekp-buffer-tests.el` and + `tests/ekp-gui-verify.el`: add RED/GREEN public-path coverage for zero-plan + same-row edits, local middle-row anchors, object-identical reversal, + forward/backward row crossing, zero-work point motion, lazy ownership, + stable yank/undo, resize, and hard completion. +- **Modify** bilingual user/developer documentation, changelog, spec, plan, + technical reference, task/issue records, and the decision postmortem to + make transaction triggers and signature-diff ownership explicit. +- **Verification:** buffer 99/99; default, seed-`20260729`, and isolated ERT + 188/188 each; C/Elisp fuzz 300/300; warnings-as-errors production/test + compile; empty checkdoc; pinned package-lint exit 0; release and 49-entry + dictionary manifest pass. The earlier exact pinned-source run remains + applicable because dictionary bytes did not change; two redundant final + fetches failed with GitHub `early EOF`, not a byte mismatch. +- **Benchmark:** with GC excluded, cache-revisit and point-motion scenarios + perform zero plans; 291 appends contain 15 structural plans. Remaining + C/Elisp structural p99 spikes move to `issue018`/`task030`. +- **GUI evidence:** reviewed PASS at + `/private/tmp/ekp-stable-transaction-final5-2BFryc`: 48.95 seconds, + 55 checkpoints, 17 assertions per checkpoint, completed run-end, one + fullscreen target window, no black segment, split, stale buffer, client + instruction, or transient blank frame. +- **Behavior/Risk:** ordinary editing is intentionally stability-first; + the current dirty row may be temporarily non-optimal until a structural + commit. Completed paragraphs still use the unchanged global KP core. + +## 2026-07-29 — Plan issue019 and task031 + +- **Add** `issue019`: per-edit whole-hard-line replanning and suffix + invalidation share one overloaded frontier and cannot express stable local + editing or exact reversible restoration. +- **Add** `task031`: replace that model with committed projection, a dirty + edit transaction, preserved unaffected anchors, and atomic structural + commits. +- **Modify** `task030` to depend on `task031`; performance work must measure + the surviving path instead of optimizing the rejected control flow. +- **Modify** the current spec, plan, technical reference, issue index, and + decision history so trigger ownership is explicit: transactions decide + when layout may change, signature diffing decides what properties change, + and the unchanged core DP decides final layout. +- **Validation planned:** focused public-path RED/GREEN ERT; complete + automated/static gates; re-profiled live benchmark; temporal GUI evidence + for stable input, local middle-line editing, exact reversible restoration, + structural commits, and zero-work point motion. +- **Behavior/Risk:** this planning entry is superseded by the completed + implementation entry above. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md new file mode 100644 index 0000000..754c819 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_implementation_20260729.md @@ -0,0 +1,286 @@ +# Change Log: Text-Property Layout Implementation 2026-07-29 + +## 2026-07-29 — Complete task027 + +- **Modify** `ekp-buffer.el`. + - Makes native soft wrapping a mode-owned precondition by temporarily + setting both `truncate-lines` and + `truncate-partial-width-windows` buffer-locally to nil before the + first reflow. + - Restores exact prior values and local/global ownership on normal + disable, major-mode teardown, and failed activation. An activation + error rolls back the full lifecycle and then remains visible. + - Centralizes installation and removal of the auto-mode hook set; no + source character, overlay, synthetic live break, or wrapping algorithm + was added. +- **Modify** buffer/GUI tests and bilingual user/developer documentation. + - Adds public lifecycle regressions for normal disable, forced reflow + failure, and major-mode teardown. + - Adds a deterministic 44-column split-window scenario and records the + native-wrap requirement in the spec, plan, issue, changelog, and + decision history. +- **Validation:** focused lifecycle ERT 3/3; buffer ERT 77/77; default, + seed-`20260729`, and isolated full ERT 166/166; C/Elisp fuzz 300/300; + warning-as-error compilation; checkdoc; pinned package-lint; release, + dictionary, pinned-source, no-overlay, stale-name, conflict, artifact, + and diff gates. +- **Dynamic evidence:** `/private/tmp/ekp-soft-wrap-final-pass-PIUigY` + retains a reviewed per-character Emacs 30.2 recording in an intentional + two-window frame. The 44-column selected window advances from one to two + visual rows with `hscroll=0`, exact source text, zero overlays, zero live + replacing breaks, and no pending edit. Manifest/report/recording/contact + SHA-256: + `53cd23f8e1be1f29545edb6daa80184d438ca87d4d7068a182faf691a8437ad8`, + `a97a74cc5a6255c2eb5992c266c727e3ed5d5556f37672ca319373a65a110e4d`, + `2b3f7c155d1f0e2feeca2bb79a86aafe612cc1489c486121491ec6268bc1de0a`, + `21a78ae849e3a1d5cda1d80ee375cd655e49b221b5192a219ac6ab11d9122d53`. +- **Behavior/Risk:** Narrow split windows now follow normal Emacs soft + wrapping while auto mode is active. A buffer that deliberately requested + truncation gets that exact setting back when the mode ends. `issue015` + stays open pending user-visible confirmation. + +## 2026-07-29 — Plan task027 + +- **Add** `issue_native_soft_wrap_20260729.md`. + - Records `issue015`: Emacs's default narrow partial-window policy + horizontally truncates the active paragraph, so the native progressive + engine never observes a completed visual row. +- **Modify** phase spec, task list, and global issue/change indexes. + - Makes native soft wrapping an explicit auto-mode lifecycle + precondition with exact restoration on teardown. +- **Behavior/Risk:** Planning and red-test preparation only. Runtime + behavior is unchanged at this entry. + +## 2026-07-29 — Complete task025 and task026 + +- **Modify** `ekp-buffer.el`. + - Deletes partial-KP live lookahead, stable anchors, line signatures, + convergence, and push/pull repair. + - Makes Emacs native redisplay own the active tail. EKP projects only + internal gap widths on completed native visual rows and reserves full + KP breaks, prefixes, and discretionary hyphens for completed hard + paragraphs. + - Invalidates a committed prefix before edits cross back into it, defers + IME composition, rejects stale generations, respects narrowing, and + keeps foreign display ownership isolated. +- **Modify** `ekp.el`, buffer tests, and GUI verification. + - Removes the now-unused partial live-flow API and replaces its + implementation-shaped tests with the approved three-state behavioral + contract. + - Adds dynamic actions for native wrap, deletion restoration, and hard + paragraph completion. +- **Modify** bilingual user/developer docs, phase spec/plan/tasks/issue, + changelog, and the live-layout decision history. + - Records the native progressive model as the current workflow without + rewriting the earlier partial-flow decision as if it never existed. +- **Validation:** focused live contract ERT 4/4; buffer ERT 75/75; default, + seed-`20260729`, and isolated ERT 164/164; fuzz 300/300; + warning-as-error compilation; checkdoc; pinned package-lint; release, + dictionary, pinned-source, ownership, no-overlay, stale-name, and diff + gates. Exact 1–64px ASCII/CJK probes and all seven GUI variants pass. +- **Dynamic evidence:** `/private/tmp/ekp-native-live-2dYu2U` retains a + 14.82-second fullscreen single-window Emacs 30.2 recording. It proves + native mixed-text wrap with zero live owned breaks, exact deletion back + to the original three native rows, and full KP only after hard newline. + Every immediate/redisplay assertion passes; the recording contains no + black segment or non-Emacs frame. Manifest/report/recording SHA-256: + `cc3a626c7f8fb68f1ef1ea4ba0dd1e276281afee7a56fb88835f49bd86f239e1`, + `0be9337a95469c6fe710518dbfa27432b4abcd511f623182021776f911887ab0`, + `f87c3adbf16e5d17860471c1a85c89dc688b24c8e08ad82172291fc87483562e`. +- **Behavior/Risk:** Underfilled editing performs no layout planning and + looks native. Completed native rows receive gap-only alignment. A hard + paragraph receives full KP only after completion or exit. Native visual + row discovery still depends on one authoritative graphical window; + alternate-width simultaneous plans remain intentionally unsupported. + `issue014` stays open pending user-visible confirmation. + +## 2026-07-29 — Plan task025 and task026 + +- **Add** `issue_natural_live_editing_20260729.md`. + - Records `issue014`: partial KP breaks and hyphens appear while typing + near the right edge. +- **Modify** the phase spec, plan, task list, and global issue/change + indexes. + - Replaces the live product contract with the approved three-state model: + native active tail, gap-only completed native rows, and full KP after + hard-paragraph completion. +- **Behavior/Risk:** Planning and red-test preparation only. Runtime + behavior is unchanged at this entry. + +## 2026-07-29 — Complete task023 and task024 + +- **Modify** `ekp-buffer.el`. + - Restores the mark marker without activating it, then restores + `mark-active` as independent editor state. + - Keeps static paragraph-edge whitespace hiding off the active live line + and clears from the source span beginning when its first line becomes + active. +- **Modify** buffer tests and GUI verification evidence. + - Adds inactive/active mark, same-turn leading/trailing space and tab, + CJK space, deletion-exposed whitespace, consecutive space/backspace, + yank, newline, and undo regressions through public command paths. + - Retains the existing overflow/pullback, paragraph transition, resize, + IME, foreign-owner, teardown, and bounded-work matrix. +- **Modify** bilingual user/developer docs, phase spec/plan/tasks/issues, + changelog, and `postmortem/20260729-active-line-edge-state.md`. + - Records the editor-state and active-line ownership rules at their + durable documentation boundaries. +- **Validation:** focused red 0/3 → green 3/3; buffer ERT 79/79; full + default and seed-`20260729` ERT 170/170; isolated fresh-process ERT + 170/170; C/Elisp fuzz 300/300; warning-as-error compilation; checkdoc; + pinned package-lint; release, dictionary, shell, artifact, stale-name, + no-overlay, and diff gates. +- **Dynamic evidence:** + `/private/tmp/ekp-interaction-clean-rdZhdD` records three actions: + showcase width change, one-space insertion, and deletion exposing that + space. Every immediate and redisplay assertion passes, with no region, + pending transaction, replacing display, overlay, black segment, or + non-Emacs window. Manifest/report/recording SHA-256: + `0eaeebd1697d69304cfd70189d7381a026b761333ab46ab1e2c5923a6e2db0c9`, + `940efdb923a831dcd4b098cafee33b9a67c472f227be1897bd54cab0693f8b68`, + `519a4ba2757b453e54a2518c83784bfed26317eae4c3e246a984f38a2d30b2f5`. +- **Behavior/Risk:** The two regressions are fixed at shared projection + owners rather than showcase keys or self-insert advice. Static committed + lines retain edge cleanup. `issue012` and `issue013` remain open only for + required user-visible acceptance. + +## 2026-07-29 — Plan task023 and task024 + +- **Add** `issue_live_editing_interaction_20260729.md`. + - Records `issue012` for inactive-mark activation during reprojection and + `issue013` for hidden active-line edge whitespace. +- **Modify** the phase spec, plan, task list, and global issue/change + indexes. + - Makes same-turn whitespace visibility and independent `mark-active` + preservation explicit product contracts. + - Adds a bounded adjacent-interaction audit instead of treating the two + reported symptoms as isolated key-handler patches. +- **Behavior/Risk:** Planning and red-test records only; runtime behavior is + unchanged at this entry. + +## 2026-07-29 — Complete task022 + +- **Add** `spec_text_property_layout_engine_20260729.md`. + - Records the current user-visible contract, command and live-edit flows, + display-property mapping, failure/rollback behavior, edge cases, and + acceptance criteria. + - Corrects a completion-audit gap: the objective required a phase spec, + but the implementation phase had only plan/technical records. +- **Modify** implementation, tests, automation, bilingual documentation, + phase records, changelog, and the live-layout postmortem. + - Links the spec from the plan, tasks, issue, and change trace. + - Closes the repository-wide text-property architecture gate after a full + diff and ownership review. + - Removes the dead `ekp-buffer--justified-spans` and + `ekp--para-glue-shrink` functions; adds no wrapper, compatibility shim, + dependency, overlay path, or source-character fallback. +- **Validation:** buffer ERT 71/71; full default and seeded-permuted ERT + 162/162; isolated fresh-process ERT 162/162; C/Elisp fuzz 300/300; + warning-as-error byte compilation; checkdoc; pinned package-lint; + release, dictionary, pinned-upstream dictionary, shell, diff, stale-name, + no-overlay, and dead-private-function scans; portable/native/debug/ + sanitizer C builds; exact 1–64px GUI glue; seven-case GUI matrix; and + dynamic overflow/type/delete temporal verification. +- **Retained GUI/dynamic evidence:** + - `/tmp/ekp-live-final-029c-fOG8xp/manifest.jsonl` + (`sha256:727305251edf07e4cee43bc6b8b25610a384e7d4f071656de388ec6e23913fd9`) + records one live Emacs window, source/projection hashes, zero overlays, + exact overflow/pullback assertions, immediate/redisplay equality, and a + completed 31,707ms run. + - The clean first and final screenshots are + `frames/000001-setup-setup.png` + (`sha256:5e324e25f278867b57c31b825c7d56c87d15b18f7fd1e99e276857127c0ddab8`) + and `frames/000008-completion-final.png` + (`sha256:f5e28d98190b8a494d7f010b423c217b104e76f8c79cc792a8359a328ae1797d`). + - `report.md` + (`sha256:fec75f10a851f220a937456fa17900c178f9466874340d1e178ca6e333693904`) + returns `PASS` with no incomplete or black segments; `recording.mov` + (`sha256:2e31a13b70c2387a0e9a08a1b87947180a46310e3f1becb8c445c768998ac347`) + is the 4,875,735-byte temporal source. +- **Independent review:** code reviewer `APPROVE`, zero findings; + architecture reviewer `CLEAR`, no unresolved architectural blocker. +- **Evidence correction:** replaces the stale duration in phase records + with 31.71 seconds, matching the retained dynamic + manifest's 31,707ms `run-end`. +- **Strengthen** `tests/ekp-buffer-tests.el`. + - Replaces presence-only display checks with exact structural assertions + for ASCII `((space-width FACTOR) (min-width ((PIXELS))))`, zero-source + CJK `(min-width ((PIXELS)))`, and the `line-prefix` pixel-space value. + - Focused exact-property ERT passed 3/3; the current full suite passed + 162/162. Warning-as-error compilation of the strengthened test file, + production checkdoc, release checks, artifact cleanliness, and + `git diff --check` passed. +- **Behavior/Risk:** The runtime and repository gates are complete. + `issue011` intentionally remains open until the user accepts the visible + editing behavior, as required by the issue lifecycle. + +## 2026-07-29 — Complete task021 + +- **Modify** `ekp-buffer.el`. + - Adds synchronous bounded live flow with a natural unfinished line, + minimal overflow push, deletion pullback, stable-line convergence, + generation-checked IME/resize work, and automatic paragraph limits. + - Keeps one source-marker anchor for a continuous editing flow so + repeated deletion cannot move the recomputation boundary into the + stable prefix. + - Releases the active anchor on paragraph exit, explicit refill, + hard-boundary reflow, conflict abandonment, and mode teardown. +- **Modify** `tests/ekp-buffer-tests.el`, `tests/ekp-gui-verify.el`. + - Adds public-hook live-edit regressions, bounded-work tests, exact + type/delete break restoration, anchor lifecycle checks, and + before/immediate/settled source/projection hash assertions. +- **Modify** bilingual user/developer docs, current plan/test specification, + changelog, and `postmortem/20260729-text-property-live-layout.md`. +- **Behavior/Risk:** Ordinary edits never run a delayed whole-paragraph + formatter. The unfinished point line remains natural; overflow and + pullback publish synchronously. Buffer ERT passed 71/71. The final + 31.71s dynamic artifact passed every checkpoint with zero overlays, + no immediate/settled hash drift, and exact deletion restoration. + +## 2026-07-29 — Complete task020 + +- **Delete** `ekp-region.el`, `tests/ekp-region-tests.el`. +- **Add** `ekp-buffer.el`, `tests/ekp-buffer-tests.el`. + - Replaces delete/insert physical layout with owned text properties on + existing source characters only. + - Maps ASCII glue to combined `space-width`/absolute-pixel `min-width`, + zero-source glue to `min-width`, indentation to `line-prefix`, and + breaks/hyphens to replacing display strings. + - Preserves foreign property owners, direct logical text APIs, copy/save/ + search semantics, point, undo, modified state, and external hooks. +- **Modify** GUI verification and bilingual user/developer documentation. +- **Behavior/Risk:** Buffer ERT passed 71/71. The clean Emacs 30.2 static + probe passed exact 1–64px ASCII/CJK targets, display-only break/hyphen, + point/vertical motion/region/mouse, unchanged source, and zero overlays; + the 7-case scale/remap/fringe/width matrix passed. + +## 2026-07-29 — Complete task019 + +- **Modify** `ekp.el`. + - Adds semantic `ekp-layout-plan`, `ekp-layout-line`, and + `ekp-layout-gap` records with source offsets, exact glue targets, + break kinds, and discretionary-hyphen decisions. + - Makes the existing reversible string renderer consume the shared plan. +- **Modify** `tests/ekp-tests.el`. + - Adds source mapping, hyphen decision, and public-dispatch regressions. +- **Modify** `DEVELOPER.md`, `DEVELOPER_ZH.md`. + - Documents the layout/rendering representation boundary. +- **Behavior/Risk:** Public string formatting remains compatible. The new + plan is the single source for the upcoming buffer display projection. + Validation: focused red 0/3 → green 3/3; full ERT 133/133; fuzz 300/300; + warning-as-error compilation, checkdoc, and diff checks clean. + +## 2026-07-29 — Plan task019 through task022 + +- **Add** + `plan_text_property_layout_engine_20260729.md`. + - Locks the shared core-plan architecture, text-property-only buffer + mapping, live active-line state machine, narrowest-window policy, + display ownership, verification gates, and rollback boundary. +- **Modify** + `task_repository_audit_20260728.md`. + - Adds atomic `task019` through `task022` for core extraction, static + projection, live editing, and final closure. +- **Behavior/Risk:** Planning records only. Runtime behavior is unchanged. + The main risk is Emacs display-property interaction; each milestone has a + stop gate before downstream implementation. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_research_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_research_20260729.md new file mode 100644 index 0000000..f098e94 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_text_property_layout_research_20260729.md @@ -0,0 +1,56 @@ +# Change Log: Text-Property Layout Research 2026-07-29 + +## task018 / issue011 + +- **Add** — + `.phrase/phases/phase-kp-overhaul-20260726/tech-refer_text_property_layout_20260729.md` + - Recorded the user-mandated text-property-only direction. + - Mapped KP boxes, Latin/CJK glue, indentation, chosen breaks, and + discretionary hyphens to Emacs display properties. + - Recorded official documentation/source evidence, exact boundaries, and + a minimum GUI verification gate. + - Proposed a non-polluting Latin hyphen representation in which the + preceding source grapheme displays as + `GRAPHEME + HYPHEN + NEWLINE`. + +- **Modify** — + `.phrase/phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md` + - Marked design research as resumed while keeping the issue open. + - Added the confirmed primitives, Emacs 30.2 GUI probe results, proposed + hyphen owner, and unresolved architecture constraints. + +- **Modify** — + `.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md` + - Added and completed documentation-only `task018`. + +- **Modify** — `.omx/plans/prd-ekp-seamless-live-layout.md`, + `.omx/plans/test-spec-ekp-seamless-live-layout.md` + - Added a supersession notice so the prior overlay-oriented prototypes + are not executed after the user's text-property-only decision. + +- **Modify** — `.phrase/docs/CHANGE.md` + - Indexed this phase change. + +## Behavior and Risk + +- No runtime source, test implementation, package API, or persisted file + format changed. +- The recorded GUI probes used temporary buffers in clean Emacs 30.2 + daemons and left no repository or user-buffer state. +- Static LTR feasibility is evidence, not proof of live editing, + multi-window independence, bidi correctness, or foreign display-property + composition. +- `issue011` remains open. + +## Validation + +- Official GNU Emacs display/text-property documentation was cross-checked + against the Emacs 30.2 display source. +- GUI glue probe: a natural 7px source space rendered at exact 3px and 20px + targets through combined `space-width` and `min-width`; source characters + were unchanged. +- GUI hyphen probe: `abcdefgh` remained eight source characters while a + property on `d` displayed `d-\n`, produced two screen lines, moved one + visual line to source position 5, and honored a 20px `line-prefix`. +- Documentation links, task/change traceability, Markdown structure, and + `git diff --check` were inspected. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md new file mode 100644 index 0000000..72754b0 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md @@ -0,0 +1,55 @@ +# Issue: C Resize Latency 2026-07-29 + +## issue020 [ ] C-backed resize reflow still takes roughly 60–70 ms + +- **Status:** Developer verification complete; user confirmation pending. +- **Summary:** Resizing with the C dynamic module enabled was observed at + roughly 60–70 ms. That number covered more than the C algorithm: the + buffer path also rebuilt layout input, crossed the Emacs-module boundary, + reconstructed layout data, and published text properties. +- **Environment:** Graphical Emacs 30.2 on Apple Silicon; C module 1.6; + portable production profile unless a benchmark explicitly labels another + profile. +- **Repro:** + 1. Load the C module and a representative mixed Latin/CJK buffer. + 2. Reflow the same source through a deterministic sequence of uncached + widths. + 3. Measure complete reflow and its plan, module, DP, and publication + layers with GC pauses reported separately. +- **Expected vs Actual:** + - Expected: candidate p95 is at most 50 ms and both p50 and p95 improve by + at least 20% against the frozen same-machine baseline. + - Actual: the final four-round evaluator records core p50/p95 of + 15.318/27.687 ms and complete resize p50/p95 of 15.900/27.487 ms. + Against the frozen baseline this is a 33.25%/34.09% core improvement + and a 43.51%/41.03% complete-resize improvement. +- **Investigation:** The portable C call averaged roughly 1.8 ms and was + not the dominant layer. Repeated paragraph resolution, projection-time + gap measurement, no-op gap records, duplicate property publication, and + publishing the active paragraph once statically and again as the live + prefix owned most avoidable work. A real GUI resize can still include an + Emacs GC pause after `set-window-margins`; that pause is reported + separately instead of being hidden by a global threshold change. +- **Required Outcome:** Reduce the real owning layer while preserving exact + frozen-C and Elisp layout parity, exact cache identity, stable live + transactions, and source-clean projection. +- **Forbidden Shortcuts:** Longer debounce, skipped intermediate widths, + stale or approximate plan reuse, asynchronous stale publication, and + global GC changes. +- **Fix:** Resolve each paragraph once per plan, carry prepared DP/gap + geometry through projection, omit true zero-source/zero-target gaps, + publish owned properties atomically, and exclude the active paragraph + from the static resize pass before installing its live prefix. +- **Verification:** Four interleaved frozen-baseline/candidate rounds pass + the core and complete-resize gates with exact frozen-C/Elisp layout + parity. The normal and permuted 193-test suites pass, 300 fuzz cases pass, + warning-as-error byte compilation, package-lint, checkdoc, release checks, + and the isolated-test run pass. Reviewed GUI evidence at + `/private/tmp/ekp-c-resize-gui-final6-FMFLxx` records four real width + changes, 14 checkpoints, no failed assertion or pixel overflow, and a + maximum EKP mutator time of 22.989 ms; one 74.099 ms total sample contains + 58.792 ms of Emacs GC and 15.307 ms of EKP work. +- **User Confirmation:** Required after implementation evidence is delivered. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_live_editing_interaction_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_live_editing_interaction_20260729.md new file mode 100644 index 0000000..12c1a05 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_live_editing_interaction_20260729.md @@ -0,0 +1,119 @@ +# Issue: Live Editing Interaction Regressions 2026-07-29 + +## issue012 [ ] Reprojection activates an inactive mark + +- **Status:** Runtime fix and repository verification are complete. The + issue remains open until the user confirms the visible behavior. +- **Summary:** Changing width in `ekp-showcase` can highlight text even + though the user did not activate a region. +- **Environment:** Text-property renderer after `task022`; Emacs 30.2. +- **Repro:** + 1. Open `ekp-showcase`. + 2. Move point so the buffer has an existing but inactive mark. + 3. Press `-`, `+`, Left, or Right to change the projection width. + 4. Observe a highlighted region between point and the old mark. +- **Expected vs Actual:** + - Expected: width changes preserve point, mark position, and whether the + region is active. + - Actual: the old mark becomes active during reprojection. +- **Investigation:** `ekp-justify-region` restores every existing mark with + `set-mark`. That command activates the mark even when it was inactive + before layout. The showcase exposes the central command defect because + every width key calls the same reprojection path. +- **Root Cause:** The projection command restores the mark's position but + does not preserve the independent `mark-active` editor state. +- **Required Outcome:** Restore the mark marker without activating it, and + prove both the central command and showcase width flow leave an inactive + mark inactive. +- **Fix:** `task023` now restores the existing mark marker directly and + restores `mark-active` independently. It never calls `set-mark` during + reprojection. +- **Verification:** The focused regression failed before the fix because + `mark-active` became non-nil, then passed after the owner-layer change. + A clean full-screen GUI width-key run preserved point 120, mark 20, + `mark_active=false`, `region_active=false`, and zero overlays before, + immediately after, and after redisplay. +- **User Confirmation:** Pending after the visible fix is delivered. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. + +## issue013 [ ] Active-line edge whitespace is hidden + +- **Status:** Runtime fix and repository verification are complete. The + issue remains open until the user confirms the visible behavior. +- **Summary:** With `ekp-auto-justify-mode` enabled, a newly typed trailing + space or tab can appear to do nothing. It becomes visible only after a + following non-whitespace glyph is typed. The same defect affects newly + typed leading whitespace and whitespace exposed by deletion. +- **Environment:** Text-property renderer after `task022`; Emacs 30.2. +- **Repro:** + 1. Enable `ekp-auto-justify-mode` in a non-empty paragraph. + 2. Move to the paragraph end and type one space. + 3. Inspect the inserted source character or type one ordinary glyph. +- **Expected vs Actual:** + - Expected: every command-loop insertion is visible immediately on the + natural active line. + - Actual: the source space exists immediately, but EKP owns + `display ""` on it until another glyph makes it an interior character. +- **Investigation:** The live renderer correctly asks `ekp--project-line` + to leave the active line's glue natural, then separately applies static + paragraph-edge cleanup. Because the KP plan strips leading and trailing + whitespace from its content bounds, that cleanup hides active-line edge + whitespace after every edit. +- **Root Cause:** Static completed-paragraph edge policy leaks into the + unfinished active-line projection. The active line is only partly + natural. +- **Required Outcome:** Keep all source characters on the point-containing + active line natural, including leading/trailing spaces and tabs. Preserve + static edge cleanup on committed lines. +- **Fix:** `task023` prevents static paragraph-edge hiding from running on + the active line. When the first planned line is active, live cleanup also + starts at the source span beginning so an old hidden leading edge cannot + survive. +- **Verification:** Focused mark/edge regressions failed 0/3 before the + fix and passed 3/3 after it. The adjacent public-command matrix covers + consecutive whitespace/backspace, yank, newline, undo, active region, + overflow/pullback, paragraph exit, resize, IME, and teardown. In the + clean GUI run, one space advanced point 67→68 and cursor x 462→469 in + the same action; the source space had neither EKP-owned nor public + replacing display immediately or after redisplay. Typing and deleting a + following glyph left that state unchanged. +- **User Confirmation:** Pending after the visible fix is delivered. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. + +## Related Audit Scope + +`task024` checks the same ownership boundary across character insertion, +consecutive whitespace, deletion/backspace, newline, yank, undo, point, +mark/region, visual-break boundaries, paragraph entry/exit, resize, and +mode teardown. New concrete defects receive new issue IDs rather than being +silently folded into these two symptoms. + +## Verification Evidence + +- Buffer ERT: 79/79. +- Full ERT: 170/170 in default order and 170/170 with seed `20260729`; + every test also passed in a fresh Emacs process. +- C/Elisp property fuzz: 300/300. +- Warning-as-error compilation: production files plus + `tests/ekp-buffer-tests.el`. +- checkdoc, pinned package-lint, release, dictionary, shell syntax, + artifact, stale-name, no-overlay, and diff checks pass. +- Dynamic artifact: + `/private/tmp/ekp-interaction-clean-rdZhdD`. + `report.md` returns `PASS`, `manifest.jsonl` records all assertions true, + and the temporal contact sheet contains one clean full-screen Emacs + window with no black segment. +- Artifact hashes: + - manifest: + `0eaeebd1697d69304cfd70189d7381a026b761333ab46ab1e2c5923a6e2db0c9` + - report: + `940efdb923a831dcd4b098cafee33b9a67c472f227be1897bd54cab0693f8b68` + - recording: + `519a4ba2757b453e54a2518c83784bfed26317eae4c3e246a984f38a2d30b2f5` + - first/final screenshots: + `8cd697130fd54b65bde6d1c06410f88c205779abce563b1e83661849dfc163e2`, + `11996136c3924aea9d539f77bd9db13b55310c089967c43091d2f61133a0d4db` diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md new file mode 100644 index 0000000..239a75c --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_logical_text_api_20260728.md @@ -0,0 +1,132 @@ +# Issue: Logical Text API Boundary 2026-07-28 + +## issue011 [ ] Direct Elisp buffer APIs observe physical layout text + +- **Status:** Runtime fix and complete repository gate implemented by + `task019`–`task022`. The issue remains open only until the user accepts + the visible behavior. +- **Summary:** The former buffer renderer wrote synthesized glue spaces, + soft newlines, discretionary hyphens, and hidden payload text into the + real buffer. Boundary adapters made save/copy/search appear logical, but + ordinary Elisp character APIs still observed the physical layout. +- **Environment:** Text-property renderer and synchronous live flow after + `task021`; Emacs 30.2. +- **Historical Repro:** + 1. Insert text into a temporary buffer. + 2. Run the pre-`task020` `ekp-justify-region` with a narrow width. + 3. Compare the original text with `buffer-string` or + `buffer-substring-no-properties`. + 4. Compare the same region through `filter-buffer-substring`. +- **Historical Evidence:** The pre-fix batch probe produced a 39-character + physical representation from a 30-character logical string. + `buffer-string` did not equal the original, while the boundary-filtered + substring did. +- **Expected vs Current:** + - **Expected:** Ordinary Elisp buffer-text APIs observe the original + character sequence while EKP's justified presentation remains visible + and editable. + - **Current implementation:** `buffer-string`, + `buffer-substring-no-properties`, direct character access, ordinary + search, syntax, markers, and saving operate on the unchanged source + characters. APIs that preserve or inspect text properties may still + observe EKP's display metadata; copy/kill strips that owned metadata. +- **Investigation:** + - The former `ekp-justify-region` deleted source text and inserted the + physical rendered string. + - Reversible `ekp-glue`, `ekp-soft-break`, `ekp-soft-hyphen`, and + `ekp-hidden` markers made restoration possible but did not keep direct + buffer reads logical. + - Save/copy/isearch adapters compensated at individual boundaries and + could not cover arbitrary Elisp consumers. + - The correct owner is the buffer representation: source characters must + remain authoritative, and layout must be presentational state only. +- **2026-07-29 Design Evidence:** + - The user ruled out overlays and selected text properties on existing + source characters as the only projection mechanism. + - `(min-width ((PIXELS)))` can add an exact absolute-pixel minimum width + without adding buffer characters. + - `(space-width FACTOR)` can shrink or stretch existing ASCII spaces. + Combined with `min-width`, it rendered a natural 7px space at exact 3px + and 20px targets in a clean GUI Emacs 30.2 probe while source text + remained `"a b"`. + - `line-prefix` can provide display-only indentation for + non-continuation lines. Native continuation lines still require + `wrap-prefix`. + - A replacing `display` string on an existing grapheme can show the + grapheme followed by a discretionary hyphen and visual newline. A GUI + probe displayed `d-\n` from the source `d` in `abcdefgh`; the source + stayed eight characters, two screen lines were produced, one + `vertical-motion` landed on source position 5, and a 20px + `line-prefix` applied to both rows. + - The proposed Latin hyphen owner is the complete grapheme immediately + before the selected break. Its display string reproduces that grapheme, + appends the hyphen and newline, and uses the `cursor` property to keep + point on the source glyph. + - Full reasoning, source links, limitations, and verification gates are + recorded in `tech-refer_text_property_layout_20260729.md`. +- **Root Cause:** Logical document state and rendered layout shared one + mutable character sequence. Reversible markers made the physical + representation recoverable, not logical. +- **Required Outcome:** Remove the leak at the representation owner rather + than adding per-command adapters. Ordinary Elisp character consumers must + see logical content without losing justified display, exact round-trip, + or seamless editing. +- **Implemented Design:** + - Logical source characters stay in the real buffer. + - The buffer renderer creates no overlay and inserts no layout character. + - Existing ASCII spaces use combined `space-width` and absolute-pixel + `min-width`; zero-source CJK/mixed glue uses `min-width` on the + preceding complete grapheme; indentation uses `line-prefix`. + - CJK breaks and Latin discretionary hyphens use replacing display + strings on an existing complete grapheme. + - Projection values have exact EKP owner properties, are nonsticky, and + update under `with-silent-modifications`. + - Foreign `display`, `line-prefix`, `wrap-prefix`, `composition`, and + `invisible` owners make only the affected hard paragraph natural and + diagnostic. + - One buffer uses the narrowest live window as its authoritative width; + simultaneous independent per-window plans are not claimed. + - Live editing uses a natural unfinished line, bounded overflow/pullback, + a stable active-flow anchor, line-signature convergence, IME deferral, + stale-generation rejection, and bounded automatic paragraph work. +- **Fix:** `ekp.el` now exposes a reusable semantic layout plan while + retaining the compatible returned-string API. `ekp-buffer.el` consumes + that plan as a source-clean text-property projection and runs a distinct + synchronous live-flow state machine. +- **Verification to Date:** + - Buffer ERT passes 79/79, including unchanged source/tick/undo/modified + state, direct logical reads, save/copy/isearch, foreign ownership, + no-overlay checks, exact property forms, overflow, deletion pullback, + exact type/delete break restoration, immediate active-line edge + whitespace, active/inactive mark preservation, anchor lifecycle, IME, + undo, resize, multiwindow width, and overload behavior. + - A clean Emacs 30.2 static probe passes exact 1–64px ASCII and + zero-source CJK glue, display-only breaks/hyphens, `line-prefix`, point, + vertical motion, region, mouse, unchanged source, and zero overlays. + The seven-case scale/remap/fringe/width matrix passes. + - A 31.71s dynamic recording passes before/immediate/settled overflow and + deletion checkpoints. Immediate and settled source/projection hashes + match; deletion restores the original source/projection hashes and + break count; every assertion is true and temporal review passes. + - Final-source default and seeded-permuted ERT pass 170/170; every one of + the 170 tests passes in a fresh Emacs process; C/Elisp fuzz passes + 300/300. + - Warning-as-error Elisp compilation, checkdoc, pinned package-lint, + release/dictionary/static gates, all four C build profiles, full diff + review, and anti-slop cleanup pass. + - Independent final code review returns `APPROVE` with no findings; + independent architecture review returns `CLEAR`. +- **User Confirmation:** On 2026-07-28, the user explicitly defined the + target as clean text through Elisp APIs and requested recording only, + with implementation deferred. On 2026-07-29, the user resumed design, + excluded overlays, selected text properties, identified `min-width`, + `space-width`, and `line-prefix`, requested a non-polluting hyphen + implementation, then explicitly requested complete implementation under + the persistent goal model. Final visible-behavior acceptance is pending. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. +- **Related:** `ekp.el`, `ekp-buffer.el`, `task017`–`task022`, + `spec_text_property_layout_engine_20260729.md`, + `tech-refer_text_property_layout_20260729.md`, + `postmortem/20260729-text-property-live-layout.md`. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_narrow_live_append_latency_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_narrow_live_append_latency_20260729.md new file mode 100644 index 0000000..6bc7dc6 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_narrow_live_append_latency_20260729.md @@ -0,0 +1,59 @@ +# Issue: Narrow Live-Append Latency 2026-07-29 + +## issue018 [ ] Unique live appends miss the frame budget at very narrow widths + +- **Status:** Re-profiled after `task031`; `task030` is unblocked. +- **Summary:** Stable transactions removed per-key whole-hard-line planning. + The synthetic 80-pixel workload now performs zero planning on ordinary + same-row edits, but structural row-crossing commits still make append p99 + exceed the 16 ms frame budget. +- **Environment:** Emacs 30.2 on macOS, C backend 1.6, benchmark width + fixed at 80 px, `gc-cons-threshold` bound to + `most-positive-fixnum` so mutator work is measured without GC pauses. +- **Repro:** + 1. Load the C backend. + 2. Run `tests/ekp-buffer-live-bench.el` with GC excluded. + 3. Inspect the `append` row. +- **Expected vs Actual:** + - Expected: live append p99 remains below the 16 ms interaction budget, + including narrow windows. + - Actual: the final task031 GC-excluded runs record only 15 permitted + structural plans across 291 appends. C measures median 2.177 ms and p99 + 51.170 ms; Elisp measures median 2.176 ms and p99 187.499 ms. Same-row + cache-revisit work records zero plans (C p99 1.627 ms; Elisp p99 + 1.502 ms), while point motion records zero plan/cache calls (C p99 + 0.017 ms; Elisp p99 0.015 ms). +- **Investigation:** + - The task029 point-motion change is not the cause. Navigation is now a + zero-work path and does not enter planning or projection publication. + - The old workload produced 291 distinct hard-line strings and 291 plan + calls. `task031` removes that obsolete control flow: only 15 actual + visual-row crossings invoke `ekp-layout-plan`. + - The remaining high percentiles coincide with those allowed structural + commits and must be profiled as commit latency, not average key latency. + - Wider measurements are materially faster; the pathological result is + width-sensitive rather than a general point-motion regression. + - The previously recorded 6.399 ms task028 append p99 is not + reproducible with the current checked-in benchmark and must not remain + the current performance claim. + - Both backends still miss p99 at structural boundaries, so task030 must + evaluate both rather than optimize only the C wrapper. +- **Required Outcome:** + - Diagnose and reduce unique-state narrow-width append planning cost + without changing KP output semantics, the core DP contract, C ABI, or + source-clean display model. + - Keep cache identity exact; do not introduce approximate/stale plan + reuse, timers, skipped edits, global GC changes, or point-based + invalidation. + - Preserve the task029 invariant that point-only motion performs zero + plan, cache, and text-property work. +- **Fix:** Deferred to `task030`; the prerequisite correctness replacement + is complete. Optimize only the surviving structural-commit path. +- **Verification:** Future work must use a reproducible width matrix, + profiler evidence, result-equivalence tests, default-GC and GC-excluded + runs, and GUI input evidence. +- **User Confirmation:** Not applicable until a performance change is + implemented. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_native_soft_wrap_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_native_soft_wrap_20260729.md new file mode 100644 index 0000000..2e5d493 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_native_soft_wrap_20260729.md @@ -0,0 +1,51 @@ +# Issue: Native Soft Wrap 2026-07-29 + +## issue015 [ ] Narrow split windows truncate live text + +- **Status:** Implemented and independently verified; user confirmation + remains required. +- **Summary:** With `ekp-auto-justify-mode` enabled in a narrow + side-by-side window, typing past the right edge horizontally scrolls the + line and displays a `$` truncation indicator instead of naturally + soft-wrapping. +- **Environment:** Graphical Emacs 30.2; side-by-side windows; native + progressive live layout. +- **Repro:** + 1. Split a graphical frame side by side until the editing window is + narrower than 50 columns. + 2. Enable `ekp-auto-justify-mode`. + 3. Type a hard line beyond the right edge. + 4. Observe horizontal scrolling and the left `$` indicator. +- **Expected vs Actual:** + - Expected: the active hard line follows native soft wrapping, point + moves onto a new visual row, and EKP may align only the row just left. + - Actual: no visual row is created, so native-row publication cannot + begin. +- **Root Cause:** Emacs 30.2 defaults + `truncate-partial-width-windows` to `50`. In narrower side-by-side + windows that setting overrides `truncate-lines=nil` and enables + truncation. The auto mode declared native soft wrap as an algorithmic + precondition but did not own the corresponding display variables. +- **Required Outcome:** While the mode is enabled, force native soft + wrapping in full-width and partial-width windows. On disable or + major-mode change, restore both the prior values and whether each value + was buffer-local. +- **Fix:** `task027` snapshots both truncation variables and their + buffer-local ownership before enabling native wrapping. Normal disable, + major-mode teardown, and failed activation restore that state exactly; + activation errors remain visible after cleanup instead of leaving a + half-enabled mode. +- **Verification:** The lifecycle regressions pass 3/3; buffer ERT passes + 77/77; default, seed-`20260729`, and isolated ERT pass 166/166; C/Elisp + fuzz passes 300/300. Warning-as-error compilation, checkdoc, pinned + package-lint, release, dictionary, pinned-source, and static gates pass. + Retained dynamic evidence in + `/private/tmp/ekp-soft-wrap-final-pass-PIUigY` shows per-character typing + naturally advancing from one to two visual rows in a 44-column split, + with `hscroll=0`, exact source text, zero overlays, and zero live + replacing breaks. +- **User Confirmation:** Pending after delivery. +- **Resolved At:** Unresolved. +- **Resolved By:** Implementation complete; issue stays open until + user-visible confirmation. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_natural_live_editing_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_natural_live_editing_20260729.md new file mode 100644 index 0000000..3eed69b --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_natural_live_editing_20260729.md @@ -0,0 +1,54 @@ +# Issue: Natural Live Editing 2026-07-29 + +## issue014 [ ] Near-edge editing publishes premature KP layout + +- **Status:** Implemented and independently verified; user-visible + confirmation is pending. +- **Summary:** With `ekp-auto-justify-mode` enabled, typing near the right + edge can publish several visual KP breaks and a discretionary hyphen + while the user is still composing the current hard paragraph. The result + does not feel like ordinary Emacs editing. +- **Environment:** Text-property live-flow renderer after `task024`; + graphical Emacs 30.2; mixed Latin/CJK prose. +- **Repro:** + 1. Enable `ekp-auto-justify-mode` in a graphical window. + 2. Type mixed Latin/CJK prose continuously toward the right edge. + 3. Observe the transition one character at a time as Emacs naturally + wraps. +- **Expected vs Actual:** + - Expected: the source tail containing point is displayed exactly as + ordinary Emacs would display it. Before natural wrap, EKP publishes no + layout property there. After natural wrap, EKP may align only the row + the user has left. A complete KP pass runs only after the hard + paragraph ends or point leaves it. + - Actual: bounded KP flow selects and publishes display breaks during + typing. `natural-p` suppresses glue projection only; it still permits + prefix and break/hyphen projection. +- **Root Cause:** The live model treats an unfinished paragraph as a + continuously maintained partial KP plan. “Natural active line” is only + a glue exception inside that plan, not native Emacs display ownership. +- **Required Outcome:** Replace partial live KP with three explicit states: + a natural active tail, locally aligned rows already left by natural + wrapping, and a complete KP paragraph after completion. No live row may + add a discretionary hyphen or replacing visual break. +- **Fix:** `task025` deletes the partial-KP + anchor/lookahead/convergence/push-pull state machine. The active hard + paragraph is restored to native display before each source edit. After + the edit, EKP derives completed native visual rows from Emacs redisplay + and projects only their internal gap widths. The point-containing tail + stays native, and only hard-paragraph completion or paragraph exit runs + the complete KP planner. Live projection cannot install replacing + breaks, prefixes, or discretionary hyphens. +- **Verification:** Focused live contract ERT passes 4/4; buffer ERT passes + 75/75; default, seeded-permuted, and isolated full ERT pass 164/164; fuzz + passes 300/300. Warning-as-error compilation, checkdoc, pinned + package-lint, release, dictionary, pinned-source, static ownership, + no-overlay, and diff gates pass. The retained clean Emacs 30.2 dynamic + artifact at `/private/tmp/ekp-native-live-2dYu2U` proves native + mixed-text wrap, exact deletion restoration, and the sole full-KP + transition after hard newline; every immediate and redisplay checkpoint + passes, with zero live owned breaks and no black recording segment. +- **User Confirmation:** Pending after the visible fix is delivered. +- **Resolved At:** Unresolved. +- **Resolved By:** Implementation complete; awaiting user confirmation. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_point_motion_projection_stability_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_point_motion_projection_stability_20260729.md new file mode 100644 index 0000000..0d9b3d9 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_point_motion_projection_stability_20260729.md @@ -0,0 +1,82 @@ +# Issue: Point-Motion Projection Stability 2026-07-29 + +## issue017 [ ] Point-only motion changes an already published live projection + +- **Status:** The zero-work point-motion invariant remains current. + `issue019`/`task031` supersede frontier-owned immediate publication and + extend the invariant across hard-line/paragraph navigation. +- **Summary:** `task028` correctly made one whole hard line the planning + unit, but it also made transient point position the persistent live + projection boundary. Moving point across semantic plan lines therefore + removes and reinstalls display properties even though source text, + authoritative width, font metrics, and layout options are unchanged. +- **Environment:** Graphical Emacs 30.2; `ekp-auto-justify-mode`; one hard + line with a multi-line semantic KP plan. +- **Repro:** + 1. Edit the end of a long hard line so EKP publishes a semantic prefix. + 2. Move point backward into an already projected semantic line without + changing source text. + 3. Observe the prefix lose projection; move point forward and observe it + return. +- **Expected vs Actual:** + - Expected: point-only motion inside the active hard line is display + read-only. The existing plan, editing frontier, active semantic index, + line signatures, projection properties, source text, and cache state + remain unchanged. + - Actual: `post-command-hook` calls + `ekp-buffer--publish-live-boundary`, derives a new active index from + point, and mutates the projected prefix. +- **Root Cause:** The live state stores a plan-relative `active-index` but + no source-relative editing frontier. `ekp-buffer--project-live-plan` + therefore reads `(point)` both after source edits and during unrelated + reprojection. This gives cursor navigation ownership of layout state. +- **Required Outcome:** + - The latest real source edit owns a source-relative editing frontier. + - `before-change-functions` naturalizes the semantic line actually being + edited and its suffix before source mutation. + - `after-change-functions` moves the frontier to the changed region's + new end and publishes exactly once. + - Point-only motion inside the same hard line performs no plan, cache, or + text-property work. + - Width/font/layout-context changes may rebuild the plan, but they map + the preserved frontier into that plan rather than reading transient + point. + - Point-only motion, including leaving the hard line, performs zero work. + The next real edit elsewhere, hard newline, or explicit refill owns the + completed-paragraph transition. + - Core DP, C ABI, DP schema, source text, and the no-overlay contract do + not change. +- **Fix:** `task029` adds a source-relative frontier marker to live state, + derives the active semantic index from it, moves it only after real + changes, preserves it through reflow, and removes point-driven + publication from `post-command-hook`. The hard-line completion path is + unchanged. +- **Verification:** + - The focused point-motion test failed before implementation because + cursor movement removed the owned projection; the reflow regression + also selected point's line instead of the edit frontier. Both pass + after the fix. + - Independent focused ERT passes 10/10; buffer ERT passes 93/93. + Default, seed-`20260729`, and isolated full suites pass 182/182; + C/Elisp fuzz passes 300/300. + - Warning-as-error compilation, checkdoc, pinned package-lint, release, + dictionary, pinned-source, no-overlay, no-stale-symbol, and diff gates + pass. + - Point motion measures 0.033 ms p99 on the C backend and 0.037 ms in an + independent Elisp-backend review, with zero planner/cache calls. + - The reviewed GUI run + `/private/tmp/ekp-frontier-live-v3-66WYRW` records 39 manifest lines + over a 26.6-second real screen capture. Backward and forward motion + preserve projection hash, plan object, generation, cache size, active + index, and frontier. Delete, yank, real undo, narrow/restore resize, + and hard newline also pass; source is exact, overlays are zero, + `hscroll` is zero, no black segment is detected, and the evidence + report returns PASS. + - Independent architecture review is CLEAR. Independent code review + reports zero blockers; its only residual is the separate narrow-width + append latency tracked by `issue018`/`task030`. +- **User Confirmation:** Pending. Keep this issue open until the user + confirms the editing experience. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_semantic_live_prefix_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_semantic_live_prefix_20260729.md new file mode 100644 index 0000000..c560ffc --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_semantic_live_prefix_20260729.md @@ -0,0 +1,97 @@ +# Issue: Semantic Live Prefix 2026-07-29 + +## issue016 [ ] Live editing freezes native rows instead of projecting a semantic hard-line prefix + +- **Status:** The whole-hard-line commit result is retained, but per-edit + replanning is superseded by `issue019`/`task031`. +- **Summary:** Before `task028`, live editing committed completed native + visual rows independently. User testing showed that earlier soft-wrapped + rows in the same hard line did not become globally KP-aligned while the + user continued typing. The behavior was row-local, not a dynamic + projection of one semantic hard-line plan. +- **Environment:** Graphical Emacs 30.2; `ekp-auto-justify-mode`; mixed + Latin/CJK hard lines that wrap across multiple visual rows. +- **Repro:** + 1. Enable `ekp-auto-justify-mode`. + 2. Type one long hard line that naturally soft-wraps into multiple rows. + 3. Continue editing on the current unfinished row without inserting a + hard newline. + 4. Observe the rows before point. +- **Expected vs Actual:** + - Expected: EKP sends the complete current hard-line source text to the + existing `ekp-layout-plan` and projects only the complete semantic + lines before the line containing the latest real source edit. Those + preceding lines may change breaks and glue together whenever text, + width, font, or layout options change. The editing-frontier line and + all following source stay naturally editable. Point-only navigation + does not change the projection. + - Actual before `task028`: the live model derived native visual rows and + committed them one at a time. Once a row was treated as completed, + later edits could not let the whole hard-line KP plan revise earlier + breaks and glue as a single semantic decision. +- **Root Cause:** `ekp-buffer` owned live editing but used native visual-row + boundaries as the persistent layout unit. That was the wrong owner + boundary. Core DP already computed the correct complete-text plan; the + buffer layer needed to consume that plan and decide which prefix was safe + to project, not invent a row-freezing live algorithm. +- **Required Outcome:** Replace native-row commitment with semantic prefix + projection: + - keep the core DP, C ABI, DP schema, and `ekp-layout-plan` contract + unchanged; + - pass the full current hard line to `ekp-layout-plan`; + - find the plan line containing the latest real source edit; + - project only the complete plan lines before that line; + - leave the edit-frontier plan line and all following source natural; + - compare old/new line signatures so unchanged prefixes are not + reinstalled; + - cache recent full hard-line plans in `ekp-buffer` only; + - fail closed to native display for conflicts, oversized hard lines, or + stale generations; + - never use overlays or insert source layout characters. +- **Fix:** `task028` replaces the native-row commit state with one current + whole-hard-line semantic plan. `task029` makes the latest real source + edit, rather than transient point, own the natural-suffix frontier. + `ekp-buffer` projects only prior lines, owns break whitespace on the + preceding semantic line, keeps the editable suffix property-free, + reuses a 16-entry buffer-local plan LRU, and updates changed projection + suffixes from common-prefix line signatures. The core DP, C ABI, schema, + and `ekp-layout-plan` contract are unchanged. +- **Refinement:** `issue017`/`task029` supersede the point-driven boundary + portion of `task028`. The persistent natural-suffix boundary belongs to + the latest source edit; transient point motion is display read-only. +- **Later Refinement:** `issue019`/`task031` supersede immediate + whole-hard-line replanning after every edit. The plan remains authoritative + at structural commits; stable same-row editing is owned by a saved + committed projection and local dirty transaction. +- **Verification:** + - Public-path and edge-case ERT pass in the 181-test default suite, the + seed-`20260729` suite, and 181 isolated Emacs processes. C/Elisp fuzz + passes 300/300. + - Warning-as-error compilation, checkdoc, the CI-pinned package-lint, + release, dictionary, pinned-source, no-overlay, and source-clean gates + pass. + - The main dynamic GUI run + `/private/tmp/ekp-semantic-live-v4-vFZTkr` exercises typing, deletion, + backward/forward point motion, yank, real undo, narrow/restore resize, + and hard newline. Its 38 checkpoints have exact source text, zero + overlays, `hscroll=0`, no pending transaction, no failed assertion, + and a reviewed temporal verdict of PASS. + - The 44-column split-window run + `/private/tmp/ekp-semantic-split-v3-uPwuOi` proves native incremental + soft wrapping, one semantic projected prefix line, a natural active + line, exact source text, zero overlays, `hscroll=0`, clean disable, and + a reviewed temporal verdict of PASS. + - A subsequent task029 audit could not reproduce the original 6.399 ms + append p99 with the current checked-in benchmark. At its synthetic + 80-pixel width, repeated GC-excluded runs instead record one plan per + unique append and roughly 33–85 ms p99. `issue018`/`task030` track this + separate performance debt. Point motion remains a zero-work path at + about 0.03 ms p99. + - Final independent code review returns APPROVE; independent architecture + review returns CLEAR. Both reviewed the latest font-context + invalidation delta. +- **User Confirmation:** Pending. Keep this issue open until the user + personally confirms the delivered editing behavior. +- **Resolved At:** Unresolved. +- **Resolved By:** Pending. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_stable_live_edit_transaction_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_stable_live_edit_transaction_20260729.md new file mode 100644 index 0000000..1bb412c --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_stable_live_edit_transaction_20260729.md @@ -0,0 +1,72 @@ +# Issue: Stable Live Edit Transaction 2026-07-29 + +## issue019 [ ] Per-edit frontier replanning destabilizes live projection and cannot restore reversible edits exactly + +- **Status:** Implemented and developer-verified by `task031`; awaiting the + required user-visible confirmation. +- **Summary:** The current live path makes one `frontier` simultaneously own + the latest edit, the natural suffix, and projection reuse. Before every + edit it clears the touched projected line and every following line; after + every edit it replans the complete hard line. This makes unrelated rows + move during continuous input and turns narrow append latency into a + symptom of the same ownership error. +- **Environment:** Graphical Emacs 30.2; `ekp-auto-justify-mode`; long mixed + Latin/CJK hard lines with an installed live prefix. +- **Repro:** + 1. Type a hard line until multiple visual rows exist. + 2. Continue typing within the unfinished row, or edit a previously + projected middle row. + 3. Observe earlier/succeeding rows and the owned text properties after each + source change. + 4. Delete a projected space and insert the identical space again. +- **Expected vs Actual:** + - Expected: ordinary same-row input leaves the committed projection + untouched. A middle-row edit opens one local dirty island while + unaffected break anchors remain installed. Small changes are absorbed by + existing glue/native wrapping; local words move only when the dirty row + no longer fits. Restoring the exact source restores the exact projection + immediately. + - Actual: `before-change` invalidates the touched line and its complete + projected suffix. `after-change` moves `frontier`, runs or reuses a + whole-hard-line plan, and republishes immediately. The transaction stores + only an old paragraph marker and deleted fragment, so it cannot restore a + committed baseline as a first-class state transition. +- **Root Cause:** `frontier` conflates three independent owners: source-edit + location, unprojected edit boundary, and plan/projection reuse boundary. + Signature diffing reduces writes after a plan is chosen, but cannot prevent + global optimal breaks from changing when a new plan is computed after + every key. +- **Required Outcome:** + - committed projection owns the last published source/plan/signatures/spans; + - a dirty edit transaction snapshots that baseline and owns only the + affected local island; + - ordinary same-row edits do no whole-hard-line DP and do not rewrite + unaffected projection; + - crossing a native visual-row boundary atomically replans the completed + hard-line prefix, leaving the new current row natural; + - the next real edit elsewhere, hard newline/paragraph completion, + explicit refill, or width/font/layout-context change is a commit event; + - point-only motion performs zero planning, cache, property, or layout work; + - a reversible edit restores source and the complete owned projection + `equal-including-properties` without depending on a best-effort replan; + - completed paragraphs still use the existing globally optimal core DP; + - no overlays or source layout characters are introduced. +- **Fix:** `task031` deletes frontier-owned immediate publication. Committed + live state now owns source/key/plan/signatures/prefix/spans; one edit + transaction snapshots that state and marker offsets, naturalizes only its + dirty island, restores exact baselines directly, and permits replanning + only at native-row or structural commit events. Static lazy-reflow chunks + exclude the active paragraph. The core DP and `ekp.el` are unchanged. +- **Verification:** Focused public-path regressions were observed red before + implementation and green afterward. Final buffer/default/random/isolated + ERT runs pass 99/99, 188/188, 188/188, and 188/188; C/Elisp fuzz passes + 300/300; compile, checkdoc, package-lint, release, and local dictionary + gates pass. The reviewed dynamic run + `/private/tmp/ekp-stable-transaction-final5-2BFryc` is PASS with 55 + checkpoints, no failed assertion, a completed run-end, and no black + segment or visual noise. Same-row and point-motion benchmark scenarios + perform zero plans; 291 appends now contain only 15 structural plans. +- **User Confirmation:** Required after temporal GUI evidence is delivered. +- **Resolved At:** Unresolved. +- **Resolved By:** `task031` developer verification; user confirmation pending. +- **Commit:** Pending. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_buffer_module_naming_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_buffer_module_naming_20260728.md new file mode 100644 index 0000000..5c20d61 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_buffer_module_naming_20260728.md @@ -0,0 +1,87 @@ +# Plan: Buffer Module Naming 2026-07-28 + +## Goal + +Rename the editor-facing buffer integration module from `ekp-region` to +`ekp-buffer` so its file, feature, customization group, configuration +variables, private namespace, tests, and documentation match the +responsibility it actually owns. + +## Intended User Path + +- Load buffer integration with `(require 'ekp-buffer)`. +- Keep public operation names whose suffix describes their real operand: + `ekp-justify-region`, `ekp-unjustify-region`, `ekp-justify-buffer`, + `ekp-unjustify-buffer`, and the region protection commands. +- Keep `ekp-auto-justify-mode`, `ekp-org-setup`, `ekp-markdown-setup`, + `ekp-refill-paragraph`, and `ekp-diagnose`; they already follow the + package-level `ekp-` namespace and describe behavior rather than the old + module filename. +- Rename the module-owned public configuration surface and all private + implementation/test symbols from `ekp-region*` to `ekp-buffer*`. + +## Scope and Order + +1. Rename `ekp-region.el` to `ekp-buffer.el` and + `tests/ekp-region-tests.el` to `tests/ekp-buffer-tests.el`. +2. Rename the provided feature, customization group, module-owned settings, + internal functions/state, and test namespace. +3. Update test runners, GUI/showcase helpers, CI, contributor commands, and + release checks that name the files or symbols. +4. Update current public/developer/audit documentation and add an + Unreleased breaking migration note. +5. Preserve historical phase/postmortem wording; add a new decision record + instead of rewriting history. + +## Compatibility Decision + +This is an explicit breaking rename requested for the current Unreleased +line. Do not retain `ekp-region.el`, provide `ekp-region`, define obsolete +aliases, or add variable aliases. The migration is one direct replacement: +`ekp-region` becomes `ekp-buffer`. Public region commands retain their +semantic names. + +## Behavior Lock + +- Existing full ERT suite: 130 tests before the rename. +- Existing buffer tests cover exact round-trip, save failure, isearch, + kill/yank, undo, mode lifecycle, resize/lazy reflow, protection commands, + and whole-buffer/DWIM paths. +- The rename changes lookup names only; rendered output and persisted bytes + must remain identical. + +## Cleanup and Fallback Inventory + +- Smell: the `ekp-region` namespace names only one operation scope while + the module owns the entire buffer/editor lifecycle. +- No dead-code, duplication, error-handling, dependency, or UI pass is in + scope. +- The selected-window fallback in width calculation is a grounded + undisplayed-buffer boundary and remains unchanged. +- No masking fallback, swallowed error, broad compatibility shim, or + escalation candidate was found. + +## Validation + +- Focused load and buffer ERT through `ekp-buffer`. +- Default, seeded-permuted, and fresh-process-isolated full ERT. +- C/Elisp fuzz to prove rendered behavior remains unchanged. +- Warning-as-error byte compilation, checkdoc, package-lint/release gates, + shell syntax, and `git diff --check`. +- Scan active runtime, tests, CI, and current documentation for stale + `ekp-region` / `ekp-region-tests` references; historical records are the + only allowed occurrences. +- Read the complete diff before closing the task. + +## Stop Condition + +The new feature loads from `ekp-buffer.el`, all renamed tests and automation +pass, current documentation presents only the new API, historical references +are explicitly contextual, and no behavior or unrelated source changed. + +## Result + +Completed as `task017`. The approved direct rename is mechanically +equivalent to the prior runtime/test implementation apart from the +customization parent and explicitly private protrusion helper. All validation +listed above passed; no compatibility shim or unrelated cleanup was added. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_text_property_layout_engine_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_text_property_layout_engine_20260729.md new file mode 100644 index 0000000..f18810c --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_text_property_layout_engine_20260729.md @@ -0,0 +1,283 @@ +# Plan: Text-Property KP Layout Engine 2026-07-29 + +The authoritative user-visible contract is +`spec_text_property_layout_engine_20260729.md`. + +## Goal + +Resolve `issue011` at the representation owner. The real buffer keeps only +the user's logical characters. EKP computes Knuth-Plass breaks, glue widths, +indentation, and discretionary hyphens as a reusable layout plan, then a +buffer renderer projects that plan with text properties on existing source +characters. + +No buffer path may insert layout spaces, soft newlines, or discretionary +hyphens. No buffer path may use overlays. + +## User Contract + +- `buffer-string`, `buffer-substring-no-properties`, direct character + access, search, syntax, markers, point, save, and ordinary editing observe + the original character sequence. +- Manual commands produce the same KP break and glue decisions as the + string API. +- `ekp-auto-justify-mode` keeps the current unfinished visual row natural and + preserves unaffected committed break anchors during continuous editing. +- Ordinary same-row edits do not call whole-hard-line planning. A dirty edit + transaction owns the local natural island and a committed projection owns + the saved source, plan, signatures, spans, and anchors. +- Native soft-wrap crossing atomically replans the completed hard-line prefix + once. Signature diffing limits the property update; core DP remains the + owner of the committed result. +- Hard newline/paragraph completion, the next real edit outside the dirty + island, explicit refill, and width/font/layout-context change are commit + events. Point-only motion is never one. +- Reversible source edits restore the saved projection exactly rather than + relying on a recomputation to happen to reproduce it. +- Layout-property updates do not change the modified flag, undo history, + character-modified tick, point, mark, or external modification hooks. +- Turning the mode off removes only EKP-owned projection properties and + restores every pre-existing property exactly. + +## Architecture + +### Core owner: `ekp.el` + +Introduce one semantic paragraph plan built from the existing `ekp-para`, +DP breaks, line glues, and source box offsets. A plan contains ordered +lines; each line records its source/box bounds, leading indentation, +interior glue targets, chosen break, and discretionary-hyphen state. + +The plan contains no buffer positions, markers, overlays, or display +property forms. + +`ekp-pixel-justify` remains the public string formatter. Its renderer +consumes the plan and preserves the existing reversible string output and +tests. The buffer renderer consumes the same plan but has different +representation rights. + +### Buffer owner: `ekp-buffer.el` + +The buffer renderer maps plan operations to existing source characters: + +- ASCII source-space glue: + `((space-width FACTOR) (min-width ((TARGET))))`. +- Zero-source CJK/mixed glue: `min-width` on the preceding complete + grapheme, with a target equal to the grapheme's natural width plus glue. +- Leading indentation/alignment: `line-prefix` over the complete planned + display line. +- Break at source whitespace: the first break-space displays as a newline; + remaining boundary whitespace displays as empty. +- CJK break: the preceding complete grapheme displays as + `GRAPHEME + NEWLINE`. +- Latin discretionary break: the preceding complete grapheme displays as + `GRAPHEME + HYPHEN + NEWLINE`, with cursor anchoring on the reproduced + grapheme. +- Paragraph-edge whitespace stripped by the KP model remains in the buffer + and displays as empty. + +All projection properties are installed through `with-silent-modifications` +and are nonsticky. Copy/kill strips only EKP projection metadata so stale +layout cannot be yanked elsewhere. + +### Display ownership + +EKP never overwrites an unowned replacing `display`, `line-prefix`, or +`wrap-prefix` value. A paragraph with a property that cannot be composed +losslessly is kept verbatim and reported by diagnostics. Removing a layout +restores the exact pre-existing property values. + +### Multiple windows + +Text properties are buffer-wide. One buffer therefore has one +authoritative plan: the narrowest live window showing that buffer, matching +the current `ekp-buffer--effective-width` behavior. Wider windows may have +unused right-side space; no window may receive an overflowing plan. +Simultaneous different KP plans for one buffer are explicitly not claimed. + +## Live Editing State Machine + +1. The active hard line owns one committed projection: baseline source, + whole-hard-line plan, line signatures, projected spans, and anchors. +2. The first edit in one visual row snapshots that state and opens a dirty + island. `before-change` removes only the affected projection; it never + clears an unrelated suffix. +3. Same-row edits update source and dirty bounds only. Existing glue and + native soft wrapping absorb local edits without whole-hard-line DP. +4. Restoring the baseline source restores the saved owned properties and + committed state immediately and closes the transaction. +5. Crossing a native soft-wrap boundary commits once: recompute or reuse the + whole-hard-line plan, derive the completed prefix, and apply the + signature-diff update as one silent publication. The new current row is + natural. +6. A real edit outside the dirty island commits the old transaction before + opening a new one. Hard newline/paragraph completion, explicit refill, and + width/font/layout-context changes are also commit boundaries. +7. Point-only motion anywhere is display read-only and cannot commit, + finalize, plan, touch cache identity, or write projection properties. +8. IME composition stays entirely native until commit. Stale generations, + foreign display ownership, unsupported shrink, oversized hard lines, or + projection failures fail closed to native display for that hard line. +9. Hard-paragraph completion uses one existing full KP quality pass and + starts a new natural active hard line. + +## Milestones and Gates + +### M1 — Core layout plan and string parity + +- Add plan structs and `ekp-layout-plan`. +- Make the string renderer consume the plan. +- Gate: every existing core rendered string remains + `equal-including-properties`; C/Elisp parity and fuzz remain green. + +### M2 — Static text-property buffer renderer + +- Replace delete/insert justification with property projection. +- Remove physical inversion/save/isearch adapters that no longer own a + character transformation. +- Gate: source characters, positions, modified state, undo, hooks, and + foreign properties are invariant; exact GUI glue/break/hyphen/indent + probes pass. + +### M3 — Seamless live-editing foundation + +- Implement composition deferral, resize generation cancellation, + visible-first large-buffer work, the single-paragraph planning guard, and + exact editor-state preservation. +- Gate: source, point/mark, whitespace, IME, resize, and teardown regressions + are locked before selecting the final live row-boundary model. + +### M4 — Product and repository closure + +- Update bilingual user/developer documentation, `issue011`, task/change + records, changelog, and a design postmortem. +- Run default/permuted/isolated ERT, fuzz, byte compilation with warnings as + errors, checkdoc, C parity/build gates, GUI matrix, dynamic verification, + anti-slop cleanup, and independent architecture/code review. + +### M5 — Interaction regression closure + +- Preserve the mark marker and `mark-active` as independent editor state + across every reprojection, including showcase width changes. +- Keep active-line edge whitespace natural while retaining edge cleanup on + committed static lines. +- Gate: focused red/green ERT, a public-command interaction matrix, clean + GUI width-key/single-space evidence, full repository gates, and user + confirmation for `issue012` and `issue013`. + +### M6 — Native progressive editing + +- Delete the partial-KP live-flow model and its lookahead/convergence state. +- Keep the active source tail under native Emacs redisplay ownership. +- Align only completed native screen rows without live break/hyphen + projection; run complete KP at hard-paragraph completion. +- Own native soft wrapping while auto mode is active, including narrow + partial-width windows, and restore the previous display-variable + ownership on teardown. +- Gate: per-keystroke mixed-text GUI evidence, backward-edit invalidation, + paragraph-completion transition, narrow split-window soft wrapping, full + repository gates, and user confirmation for `issue014` and `issue015`. + +Historical note: M6 is superseded for live planning by M7. Its active-tail +and soft-wrap lifecycle decisions remain prerequisites, but native visual +rows are not the durable planning unit. + +### M7 — Semantic hard-line prefix editing + +- Status: implementation, automated/performance/GUI gates, and independent + code/architecture reviews are complete. `issue016` remains open for the + user's visible editing confirmation. +- Replace native-row commitment with whole-hard-line plan consumption in + `ekp-buffer`. +- Keep `ekp.el`, core DP semantics, C ABI, DP schema, and + `ekp-layout-plan` contracts unchanged. +- Project only complete semantic plan lines before point; keep the + point-containing plan line and all following source natural. +- Add buffer-local history cache entries keyed by hard-line text, + text-property/layout context, authoritative width, font/face/text-scale + context, and EKP layout parameters. +- Use semantic line signatures to avoid rewriting unchanged prefixes and + to prove later edits can revise earlier breaks/glue together. +- Gate: public-path red/green ERT, GUI dynamic recording, latency/cache + benchmark, full repository gates, independent review, and user + confirmation for `issue016`. + +### M8 — Source-edit-owned live frontier + +- Status: superseded by M10 for edit-trigger ownership. The zero-work + point-motion invariant remains current; `issue017` remains open for the + user's visible confirmation. +- Preserve the latest real source-edit position as the live frontier. +- Make point-only motion within the active hard line perform no DP, cache, + or text-property work. +- Preserve the frontier across width/font/layout-context reflow and map it + into the resulting whole-hard-line plan. +- Gate: focused property-identity RED/GREEN ERT, public earlier-line edit, + reflow-after-motion regression, full repository gates, dynamic GUI + point-motion evidence, independent review, and user confirmation. + +### M9 — Narrow unique-append latency + +- Status: re-profiled as `issue018`/`task030` after M10; ready for work. + Ordinary same-row planning is gone, so only structural-commit spikes are + in scope. +- Establish a repeatable width/length matrix before optimizing. +- Reduce new-source-state planning cost without stale plan reuse, + debounce, skipped publication, global GC changes, or weaker layout + semantics. +- Keep task029's point-motion zero-work invariant as a permanent gate. + +### M10 — Stable live projection transactions + +- Status: complete as `issue019`/`task031`; automated, static, benchmark, + and reviewed temporal GUI gates pass. `issue019` remains open only for + user-visible confirmation. +- Replace frontier-owned native suffix invalidation with a committed + projection baseline and one persistent dirty edit transaction. +- Preserve unaffected break anchors during middle-line edits; use native + soft wrapping for local word migration. +- Replan only at soft-wrap or structural commit boundaries and publish the + changed prefix atomically. +- Restore reversible edits exactly from the saved baseline. +- Preserve core DP/C ABI/schema/plan contracts and the point-motion + zero-work invariant. +- Gate: focused RED/GREEN public-path ERT, complete repository gates, + re-profiled live benchmarks, and temporal GUI evidence. + +### M11 — C-backed resize latency + +- Status: `task032` developer-complete; `issue020` awaits user confirmation. +- Freeze the current portable module and measure an interleaved + baseline/candidate width-and-length matrix with raw p50/p95 evidence. +- Attribute complete resize time across plan construction, Emacs/C + marshalling, C DP, and projection publication before selecting a change. +- Require exact frozen-C and Elisp layout parity; no stale width reuse, + debounce inflation, skipped reflows, approximate planning, or global GC + workaround is acceptable. +- Gate: at least 20% paired p50/p95 improvement, candidate p95 at or below + 50 ms, complete automated/static gates, and temporal GUI resize evidence. +- Result: core p50/p95 improved by 33.25%/34.09% to 15.318/27.687 ms; + complete resize improved by 43.51%/41.03% to 15.900/27.487 ms. Exact + frozen-C/Elisp parity and all automated, static, and GUI gates pass. + +## Stop Gates + +- Stop the affected paragraph instead of stealing a foreign replacing + display owner. +- Stop exact shrink projection for tabs or non-ASCII whitespace when + `space-width` cannot express it; keep that paragraph verbatim and report + why. +- Stop if the live-edit implementation appears to require a core DP, C + ABI, DP schema, or `ekp-layout-plan` semantic change; that means the + buffer/core boundary has been crossed incorrectly. +- Stop and return to diagnosis after two failed fixes for the same display + or live-edit invariant. +- Do not claim completion while any source-character, undo, modified-state, + property-restoration, GUI, or dynamic-edit invariant is unproved. + +## Rollback + +The string renderer can be restored to the current direct implementation +because M1 preserves its public result. The buffer renderer can be reverted +to the last released physical representation only as a full rollback; no +compatibility shim or mixed physical/property backend will be retained. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/spec_text_property_layout_engine_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/spec_text_property_layout_engine_20260729.md new file mode 100644 index 0000000..b5c9976 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/spec_text_property_layout_engine_20260729.md @@ -0,0 +1,192 @@ +# Spec: Source-Clean Text-Property KP Layout 2026-07-29 + +## Summary + +`ekp-buffer.el` presents Knuth-Plass layout without rewriting the buffer's +character stream. The core computes a reusable semantic layout plan; +manual buffer commands and `ekp-auto-justify-mode` project that plan using +text properties on existing source characters only. + +The character sequence remains the document. Visual glue, breaks, +indentation, and discretionary hyphens are display metadata: EKP creates no +overlay and inserts no layout character. + +## Goals + +1. Keep `buffer-string`, `buffer-substring-no-properties`, direct character + access, search, syntax, markers, saving, and ordinary editing on the + original character sequence. +2. Use the same core glue, break, indentation, and hyphen decisions for the + compatible string renderer and the non-mutating buffer renderer. +3. Make automatic layout stable while editing: committed prefix lines keep + their projection until a structural commit, the current unfinished visual + row stays under Emacs redisplay ownership, and a middle-line edit + naturalizes only its local dirty island while preserving unaffected break + anchors. +4. Preserve undo, modified state, character-modified tick, point, mark, + copy/kill, isearch, IME composition, narrowing, and mode lifecycle. +5. Fail closed around display ownership: unsupported or conflicting hard + paragraphs remain naturally displayed and are diagnosable. + +## Non-Goals + +- Hiding EKP metadata from Lisp that explicitly inspects text properties. + The clean-text guarantee concerns the character sequence; copy/kill strips + EKP-owned projection metadata. +- Independent simultaneous layouts for different-width windows showing the + same buffer. The narrowest live window is authoritative. +- Shrinking tabs or non-ASCII whitespace with `space-width`. +- Claiming verified bidi, Arabic, Hebrew, Indic shaping, ligatures, or + arbitrary multi-codepoint grapheme behavior beyond the tested LTR + Latin/CJK boundary. +- Retaining `(require 'ekp-region)` or old private/configuration names. + +## User Flows + +### Load and use buffer commands + +1. The user adds the package to `load-path`. +2. The user loads `(require 'ekp-buffer)`. +3. `M-x ekp-justify-region` projects the active region, or the hard + paragraph at point when no region is active. +4. `M-x ekp-justify-buffer` projects the accessible buffer. +5. `M-x ekp-unjustify-region` or `ekp-unjustify-buffer` removes only + EKP-owned projection properties; source characters do not need + restoration. + +### Edit with automatic layout + +1. The user enables `M-x ekp-auto-justify-mode`. +2. A committed projection records the complete source baseline, one + whole-hard-line `ekp-layout-plan`, line signatures, projected spans, and + break anchors. Core DP receives no point, marker, window, or edit-state + input. +3. Ordinary input within the current native visual row performs no + whole-hard-line planning. Previously committed rows remain + property-identical and the current row stays natural. +4. Editing a committed middle row starts a dirty transaction. Only the + affected local island becomes natural; following and preceding unaffected + break anchors remain installed. Native wrapping absorbs local push/pull. +5. Crossing into the next native visual row atomically replans the completed + hard-line prefix once. Line-signature diffing limits property writes; the + new current row remains natural. +6. A hard newline/paragraph end, the next real edit outside the dirty island, + explicit refill, or width/font/layout-context change commits the + transaction. Point-only motion anywhere performs zero plan, cache, + property, or layout work. +7. Restoring the baseline source restores the saved projection immediately + and exactly, including text properties, plan signatures, and spans. +8. Completed paragraphs use the existing full Knuth-Plass projection. +9. Disabling the mode cancels pending work, detaches markers, restores any + prior copy filter, and removes every EKP-owned projection. + +### Use logical text during projection + +1. Search and isearch operate directly on source characters, including + across display-only breaks and discretionary hyphens. +2. Save and `write-region` serialize source characters; visual layout never + reaches disk. +3. Copy/kill composes with an existing + `filter-buffer-substring-function`, removes only EKP-owned projection + metadata from the copied string, and preserves delete semantics. +4. Lisp that needs a property-free string uses + `buffer-substring-no-properties`; property-aware APIs may inspect the + EKP display metadata without seeing synthesized layout characters. + +### Diagnose an unsupported paragraph + +1. EKP detects foreign `display`, `line-prefix`, `wrap-prefix`, + `composition`, or `invisible` ownership, or an exact shrink request for + unsupported whitespace. +2. EKP leaves only that hard paragraph natural instead of stealing or + approximating the foreign representation. +3. `M-x ekp-diagnose` reports the authoritative width and recorded + conflicts. The user may remove the conflict or explicitly refill after + changing the content/configuration. + +## Display Contract + +- ASCII source spaces use: + + ```elisp + ((space-width FACTOR) + (min-width ((TARGET-PIXELS)))) + ``` + +- A zero-source CJK/mixed gap applies `min-width` to the preceding complete + grapheme, with a target equal to its natural advance plus the planned + glue. +- Indentation uses `line-prefix`. +- A source-whitespace break displays the first boundary character as a + newline and any remaining boundary whitespace as empty. +- A CJK or Latin discretionary break uses a replacing display string on an + existing complete grapheme. It reproduces that grapheme, appends the + optional hyphen, then a visual newline. +- Every public projection value has an EKP owner property. Cleanup removes + a public value only while it is still identical to the owned value. +- All projection updates use `with-silent-modifications`; owned properties + are nonsticky. + +## Edge Cases + +- Active IME composition defers projection and retries only for the current + generation. +- Stale resize/background generations cannot publish. +- A hard paragraph over `ekp-auto-justify-paragraph-limit` remains natural + during automatic work; explicit `ekp-refill-paragraph` is unbounded. +- Large buffers reflow visible-first in time-bounded hard-paragraph chunks. +- Foreign property conflicts are isolated to their hard paragraph. +- Narrowing does not cause projection cleanup or installation outside the + intended accessible operation; mode teardown widens to remove all owned + state. +- Major-mode changes and mode disable leave the logical text and prior + integrations intact. +- Reprojection preserves point, the mark marker, and `mark-active` + independently. An inactive historical mark must never become a visible + selection merely because width or layout options changed. +- Semantic live-prefix editing still requires real Emacs soft wrapping for + the active natural suffix. + `ekp-auto-justify-mode` temporarily disables both `truncate-lines` and + narrow partial-window truncation, then restores their prior values and + local/global binding state when the mode ends. + +## Acceptance Criteria + +1. `ekp.el` exposes a semantic layout plan containing source offsets, + per-line glue targets, break kinds, indentation, and discretionary + hyphen decisions; the public string API remains + `equal-including-properties` compatible. +2. No EKP buffer path creates an overlay or inserts glue spaces, soft + newlines, or discretionary hyphens into source text. +3. ASCII gaps use the exact combined `space-width`/absolute-pixel + `min-width` form; zero-source gaps, indentation, breaks, and hyphens use + the display contract above. +4. Source characters, positions, point/mark, modified state, undo history, + character-modified tick, save output, search, and syntax remain logical. +5. Automatic editing proves committed-projection and dirty-transaction + ownership: ordinary same-row edits perform no whole-hard-line planning or + unaffected property writes; middle-row edits preserve unrelated anchors; + reversible edits restore the complete projection + `equal-including-properties`; soft-wrap crossing publishes one atomic + whole-hard-line prefix update; structural commit events use the existing + `ekp-layout-plan`; and point-only motion performs zero layout work. IME + deferral, stale resize rejection, and exactly one complete KP pass after + hard-paragraph completion remain required. Narrow side-by-side windows + soft-wrap rather than horizontally scroll. A single leading/trailing + space or tab is visible in the same command-loop turn, including + whitespace exposed by deletion. +6. Copy/kill, isearch, foreign ownership, multiwindow narrowest-width + policy, narrowing, resize, major-mode change, and teardown have + deterministic tests. +7. Exact GUI probes cover 1–64px ASCII and zero-source CJK glue, + display-only break/hyphen/indentation, point, vertical motion, region, + mouse, scaling/remapping/fringes, unchanged source, and zero overlays. +8. Dynamic per-keystroke evidence proves mixed Latin/CJK same-row typing + preserves the committed projection, a real visual-row crossing atomically + publishes all completed rows from one hard-line plan, exact source + reversal restores the baseline projection, point-only motion preserves + every state object and property exactly, and paragraph completion performs + the full completed-paragraph KP transition. +9. Default, permuted, and isolated ERT; C/Elisp fuzz; warning-as-error + Elisp/C builds; checkdoc; package/static/release gates; full diff review; + independent code review; and independent architecture review pass. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md index a071056..56c3742 100644 --- a/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md +++ b/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md @@ -225,3 +225,470 @@ profiles, release/dictionary/static gates, and GUI matrix 7/7 passed. Independent review confirmed the implementation fix and architecture ownership; `issue010` is resolved. + +- task017 [x] Rename the editor integration module around its buffer owner. + - Source: user request and `plan_buffer_module_naming_20260728.md`. + - Behavior lock: run the existing 130-test ERT suite before editing; the + rename must preserve rendered output, logical serialization, and every + public region/buffer workflow. + - Change: rename the file/feature/customization/configuration/private/test + namespaces from `ekp-region` to `ekp-buffer`, while keeping public + operation names that correctly describe region or buffer operands. + - Validation: focused/default/permuted/isolated ERT, C/Elisp fuzz, + warning-as-error byte compilation, checkdoc, release/package/static + gates, stale-name scan, and full diff review. + - Impact: editor integration source/tests, automation, bilingual public + and developer documentation, current audit records, phase records, + changelog, and a naming decision postmortem. + - Completed: 2026-07-28; pre-change ERT 130/130; focused buffer ERT 44/44; + default and seeded-permuted ERT 130/130; every one of 130 ERT tests + passed in a fresh Emacs process; C/Elisp fuzz 300/300; warning-as-error + byte compilation, checkdoc, pinned package-lint, release/shell/diff + gates, active stale-name scan, and mechanical equivalence check passed. + +- task018 [x] Record the text-property-only layout and hyphen feasibility. + - Source: `issue011`, the user's 2026-07-29 display-property constraints, + and `tech-refer_text_property_layout_20260729.md`. + - Scope: documentation and clean GUI probes only; no runtime source or + test implementation changes. + - Evidence: confirm absolute-pixel `min-width`, real-space + shrink/stretch through `space-width`, `line-prefix` indentation, and a + replacing display string that renders a source grapheme plus + discretionary hyphen and visual newline without changing source + characters. + - Validation: official GNU Emacs documentation/source review; clean + Emacs 30.2 GUI glue, hyphen/newline, vertical-motion, and line-prefix + probes; document link/traceability review; `git diff --check`. + - Impact: `issue011`, one new technical reference, current OMX plan/test + supersession notices, phase/global change records; no runtime behavior. + - Completed: 2026-07-29. Static LTR feasibility is recorded; `issue011` + remains open for the full GUI/editor-semantic gate, live editing, and + the one-buffer/multiple-window decision. + +- task019 [x] Extract a semantic KP layout plan and preserve string output. + - Source: `issue011`, + `spec_text_property_layout_engine_20260729.md`, and + `plan_text_property_layout_engine_20260729.md` M1. + - Red tests: require line/source/gap/break/hyphen plan data and require the + existing string renderer to consume that plan without changing its + `equal-including-properties` output. + - Validation: focused red/green ERT, full core ERT, C/Elisp parity, fuzz, + warning-as-error byte compilation, and checkdoc. + - Impact: `ekp.el`, core tests, developer reference, and phase records. + - Completed: 2026-07-29; focused plan tests 0/3 → 3/3; the public string + renderer consumes `ekp-layout-plan`; full ERT passed 133/133, C/Elisp + fuzz passed 300/300, and warning-as-error compilation, checkdoc, and + diff checks were clean. + +- task020 [x] Replace physical buffer formatting with text-property projection. + - Source: `issue011`, + `spec_text_property_layout_engine_20260729.md`, and + `plan_text_property_layout_engine_20260729.md` M2. + - Red tests: unchanged source characters/positions/ticks/undo/modified + state; exact cleanup and foreign-property restoration; no overlays or + synthesized layout characters; copy/save/isearch see logical text. + - Validation: focused ERT plus clean Emacs 30.2 GUI glue, CJK break, + Latin discretionary hyphen, indentation, point, region, and mouse probes. + - Impact: `ekp-buffer.el`, buffer/GUI tests, commands, integrations, + bilingual docs, and phase records. + - Completed: 2026-07-29. `ekp-buffer.el` now projects only onto existing + source characters with owned, nonsticky text properties and creates no + overlay. Buffer ERT passed 71/71. A clean Emacs 30.2 GUI probe rendered + exact 1–64px ASCII and zero-source CJK glue, display-only Latin hyphens + and breaks, `line-prefix`, point/vertical motion/region/mouse behavior, + unchanged source text, and zero overlays. The 7-case scale/remap/fringe/ + width matrix passed. + +- task021 [x] Implement active-line and bounded-suffix live layout. + - Source: the user's seamless-editing requirement, + `spec_text_property_layout_engine_20260729.md`, and + `plan_text_property_layout_engine_20260729.md` M3. + - Red tests: unfinished line stays natural; overflow pushes a minimal + suffix; deletion pulls back; stable prefix does not move; stale + generations cancel; composition defers; no delayed whole-paragraph snap. + - Validation: deterministic ERT, performance budgets, and dynamic GUI + recordings with temporal review. + - Impact: `ekp-buffer.el`, live-edit state/tests/benchmarks, mode help, + bilingual docs, and phase records. + - Completed: 2026-07-29. Live edits publish synchronously from a stable + active-flow anchor, preserve a natural unfinished line, push the + minimal overflow suffix, pull text back on deletion, converge on an + unchanged line signature, defer IME composition, reject stale + generations, and bound both lookahead and automatic paragraph work. + The type/delete round-trip regression failed with an extra break before + the stable-anchor fix and now restores exact prior break positions. + Buffer ERT passed 71/71. The 31.71s dynamic GUI artifact recorded + before/immediate/settled overflow and deletion checkpoints; source and + projection hashes were stable after redisplay, deletion restored the + original hashes and two breaks, all assertions passed, zero overlays + were present, and temporal review returned PASS. + +- task022 [x] Close the text-property layout architecture and quality gates. + - Source: `issue011`, + `spec_text_property_layout_engine_20260729.md`, and + `plan_text_property_layout_engine_20260729.md` M4. + - Work: resolve documentation drift, record the one-width-per-buffer and + display-conflict decisions, remove dead physical-layout adapters, run + anti-slop cleanup, and obtain independent reviews. + - Validation: default/permuted/isolated ERT, fuzz, warning-clean Elisp/C + builds, checkdoc/static/release gates, GUI matrix, dynamic evidence, + full diff review, code-reviewer APPROVE, and architect CLEAR. + - Impact: runtime/tests/docs/postmortem and `issue011` closure. + - Completed: 2026-07-29. Buffer ERT passed 71/71; full default and + seeded-permuted ERT passed 162/162; every one of 162 tests passed in a + fresh Emacs process; C/Elisp fuzz passed 300/300. Warning-as-error + byte compilation, checkdoc, pinned package-lint, release, bundled and + pinned-upstream dictionary, shell, diff, stale-name, no-overlay, and + dead-private-function gates passed. Portable, native, debug, and + sanitizer C profiles built cleanly. Static GUI verification passed + exact 1–64px ASCII/CJK glue and the seven-case display matrix; dynamic + verification passed the overflow/type/delete round trip with zero + overlays and stable source/projection hashes. Anti-slop cleanup removed + the dead `ekp-buffer--justified-spans` and `ekp--para-glue-shrink` + functions. Independent code review returned `APPROVE` with no findings; + independent architecture review returned `CLEAR`. `issue011` remains + open only for the required user-visible behavior confirmation. + - Completion-audit correction: the final objective named a phase spec, + but the phase initially had none. Added + `spec_text_property_layout_engine_20260729.md` and linked it from the + plan, tasks, issue, and change record before claiming goal completion. + The same audit corrected the dynamic duration to the manifest's + 31.707-second `run-end` value and strengthened the existing display + tests to assert the exact nested `space-width`, `min-width`, and + `line-prefix` values. The strengthened focused tests passed 3/3; the + current full suite passed 162/162; warning-as-error test compilation, + checkdoc, release, artifact-cleanliness, and diff gates remained clean. + +- task023 [x] Preserve editor selection state and natural active-line edges. + - Source: `issue012`, `issue013`, and + `spec_text_property_layout_engine_20260729.md`. + - Red tests: reprojection must preserve an inactive mark; one + command-loop space/tab at either active-line edge and whitespace + exposed by deletion must carry no EKP replacing display. + - Work: restore the mark marker without changing `mark-active`; prevent + completed-paragraph edge hiding from running on the active live line. + - Validation: focused red/green ERT plus clean GUI width-key and + single-space checkpoints. + - Impact: `ekp-buffer.el`, buffer/GUI tests, user docs, issue/change + records, and the live-layout decision record. + - Completed: `ekp-justify-region` now restores the mark marker and + `mark-active` independently. Live projection never applies static edge + hiding to the active line and clears from the source span beginning + when its first line is active. Focused red 0/3 → green 3/3; clean GUI + width and single-space checkpoints pass. + +- task024 [x] Audit the live interaction matrix for adjacent regressions. + - Source: the user's request for detailed seamless-editing review and + `issue_live_editing_interaction_20260729.md`. + - Work: exercise insertion, consecutive whitespace, deletion/backspace, + newline, yank, undo, point, inactive/active mark, visual-break + boundaries, paragraph transitions, resize, and teardown through public + command paths. + - Validation: deterministic matrix ERT, full repository gates, and a + temporally reviewed GUI recording with explicit invariants. + - Impact: tests/verification artifacts and only root-cause runtime fixes + for newly reproduced defects. + - Completed: added public-command coverage for active/inactive regions, + edge whitespace, consecutive spaces/backspace, yank, newline, undo, + and deletion-exposed whitespace; existing tests cover the remaining + flow, resize, composition, transition, and teardown boundaries. Buffer + ERT passes 79/79; default/random/isolated full ERT passes 170/170; + fuzz passes 300/300; compiler, lint, release, dictionary, static, diff, + and clean dynamic GUI gates pass. + +- task025 [x] Replace partial live KP with natural progressive editing. + - Source: `issue014`, the user's approved three-state model, and + `spec_text_property_layout_engine_20260729.md`. + - Red tests: an underfull active tail owns no EKP layout property; natural + wrap aligns only completed rows without a replacing break or live + hyphen; editing/backspacing into a committed row restores native + display; hard-paragraph completion permits one complete KP pass. + - Work: delete the stable-anchor/lookahead/convergence/push-pull live + state machine. Keep a natural active tail, project only gaps on rows + already left by native screen wrapping, and run full KP only at hard + paragraph completion or paragraph exit. + - Validation: focused red/green ERT, full repository gates, and + per-keystroke mixed Latin/CJK GUI recording across wrap and deletion. + - Impact: `ekp-buffer.el`, core live-flow API if it becomes unused, + buffer/GUI tests, bilingual docs, spec/plan, issue/change records, and + the live-layout postmortem. + - Completed: removed stable-anchor/lookahead/convergence/push-pull flow + and its unused core signatures. The live path now clears the active + hard paragraph to native display, commits only internal gap widths on + native rows already left by point, keeps the active tail untouched, + and runs complete KP only on hard-paragraph completion or exit. Live + rows cannot publish prefixes, replacing breaks, or discretionary + hyphens. + +- task026 [x] Close natural-live-edit verification and documentation. + - Source: `task025`, `issue014`, and the repository completion contract. + - Work: remove obsolete tests/docs/private APIs, update the durable + design record, inspect the full diff, and retain deterministic temporal + evidence. + - Validation: default/random/isolated ERT, fuzz, warning-as-error + compilation, checkdoc, pinned package-lint, release/dictionary/static + gates, clean fullscreen GUI screenshots, and reviewed recording. + - Impact: tests, documentation, phase closure records, and no additional + runtime abstraction. + - Completed: buffer ERT 75/75; default, seeded-permuted, and isolated full + ERT 164/164; fuzz 300/300; compiler, checkdoc, pinned package-lint, + release, dictionary, pinned-source, ownership, no-overlay, stale-name, + and diff gates pass. Seven static GUI variants pass. The retained + 14.82-second fullscreen recording + `/private/tmp/ekp-native-live-2dYu2U/recording.mov` proves native wrap, + deletion restoration, and hard-newline completion without flicker or + non-Emacs frames. `issue014` remains open only for required + user-visible confirmation. + +- task027 [x] Make native soft wrapping an owned auto-mode precondition. + - Source: `issue015`, the user's narrow split-window screenshot, and + `spec_text_property_layout_engine_20260729.md`. + - Red tests: enabling `ekp-auto-justify-mode` must locally disable both + explicit line truncation and Emacs's default narrow partial-window + truncation; normal disable, activation failure, and major-mode teardown + must restore the exact prior values and local-binding ownership. + - Work: give the minor-mode lifecycle temporary ownership of + `truncate-lines` and `truncate-partial-width-windows`. Do not synthesize + a break, alter source text, or permanently overwrite user settings. + - Validation: focused red/green ERT, split-window GUI typing across the + native wrap boundary, full repository gates, and temporal review. + - Impact: `ekp-buffer.el`, buffer/GUI tests, bilingual docs, spec, + issue/change records, and the native-live postmortem. + - Completed: the mode owns native soft wrapping before its first reflow, + shares one hook lifecycle for activation and cleanup, and uses + `unwind-protect` so a failed activation restores state before + propagating the original error. Focused lifecycle ERT passes 3/3; + buffer ERT 77/77; default, seed-`20260729`, and isolated full ERT + 166/166; fuzz 300/300; compiler, checkdoc, pinned package-lint, + release, dictionary, pinned-source, no-overlay, stale-name, conflict, + artifact, and diff gates pass. Retained 44-column split-window + evidence proves native wrap with `hscroll=0`, exact source text, zero + overlays, and zero live replacing breaks. `issue015` remains open only + for required user-visible confirmation. + +- task028 [x] Replace native-row live commitment with semantic hard-line + prefix projection. + - Source: `issue016`, the user's multi-row live-editing screenshot, and + `postmortem/20260729-whole-hard-line-live-prefix.md`. + - Red tests: through public editing paths, require the complete current + hard line to be planned by the existing `ekp-layout-plan`, require only + plan lines before the point-containing line to be projected, require + earlier breaks/glue to change together after later edits, and require + plan cache hits/zero writes when the semantic plan is unchanged. + - Work: delete the native-row freezing model from `ekp-buffer` live + editing. Keep the core DP, C ABI, DP schema, and completed-paragraph + plan semantics unchanged. Add buffer-local live plan history, + line-signature diffing, point-movement boundary updates, fail-closed + conflict handling, and exact cleanup without overlays or source layout + characters. + - Validation: focused red/green ERT for self-insert, yank, delete, real + undo/redo, point movement, hard newline, resize, major-mode change, and + projection failure; buffer/full/random/isolated ERT; C/Elisp fuzz; + warning-as-error compilation; checkdoc; package/release/dictionary/ + no-overlay/static gates; performance benchmark with latency and cache + counters; clean GUI dynamic recording proving aligned semantic prefix, + natural point line/suffix, exact source text, zero overlays, + `hscroll=0`, and no delayed snap. + - Impact: `ekp-buffer.el`, live-edit tests/benchmarks, GUI verification, + bilingual user/developer docs, spec/plan/tech reference, issue/change + records, and the superseding live-prefix postmortem. + - Completed: 2026-07-29. The implementation consumes one complete + hard-line `ekp-layout-plan`, projects only semantic lines before the + point-containing line, keeps the active line/suffix natural, assigns + break whitespace to the preceding semantic owner, uses a bounded + 16-entry live plan LRU, and publishes only the changed signature + suffix. No task028 change was made to core DP semantics, the C ABI, + DP schema, or plan contract. + - Verification: focused font-context invalidation RED 0/1 → GREEN 1/1; + buffer ERT 92/92; default and seed-`20260729` full ERT 181/181; + 181/181 isolated-process ERT with the added regression independently + green; C/Elisp fuzz 300/300; warning-as-error compilation, checkdoc, + pinned package-lint, release, dictionary, pinned-source, static + ownership, and diff gates pass. Reviewed temporal GUI evidence at + `/private/tmp/ekp-semantic-live-v4-vFZTkr` and + `/private/tmp/ekp-semantic-split-v3-uPwuOi` returns PASS. The + GC-excluded C-backend append p99 is below one frame; default-GC raw p99 + retains a documented collection-pause risk. Independent code review is + APPROVE and architecture review is CLEAR. + - Closure: task028's implementation gate is complete. `issue016` remains + open until the user personally confirms the visible editing + experience. + +- task029 [x] Make the semantic live prefix independent of point-only + motion. + - Source: `issue017`, the user's clarification that an already published + layout is not cursor-owned, and + `postmortem/20260729-editing-frontier-not-point.md`. + - Red tests: after a real source edit publishes a multi-line prefix, + moving point backward and forward inside the same hard line must + preserve the exact owned-property projection, editing frontier, + active index, signatures, plan/cache counters, modified tick, undo + state, and source characters. A real edit in an earlier projected line + must still naturalize that line before mutation and relocate the + frontier afterward. Reflow after point motion must map the preserved + frontier into the new plan. + - Work: store the latest real source-edit position in the live state, + derive the active semantic line from it, preserve it across + width/font/layout reflow, and delete point-driven boundary publication + from `post-command-hook`. Keep leaving-hard-line completion unchanged. + - Constraints: do not modify core DP semantics, `ekp.el`, C ABI, DP + schema, or the layout-plan contract; do not add overlays or source + layout characters. + - Validation: focused RED/GREEN ERT; complete buffer/default/random/ + isolated ERT; C/Elisp fuzz; compiler/checkdoc/package/release/ + dictionary/static gates; benchmark; clean dynamic GUI motion evidence; + full diff review; independent code and architecture review. + - Impact: `ekp-buffer.el`, buffer and GUI tests, benchmark assertions, + bilingual user/developer docs, spec/plan/technical reference, + issue/change records, and the superseding frontier postmortem. + - Completed: 2026-07-29. Live state now owns a source-relative frontier + marker at the latest real edit. Projection derives its active semantic + line from that marker; `after-change` relocates it, reflow preserves + it, and `post-command-hook` no longer republishes inside the active + hard line. Leaving the hard line and hard-newline completion retain the + existing static transition. Core DP semantics, `ekp.el`, C ABI, schema, + and the layout-plan contract are unchanged. + - Verification: the two focused regressions failed before the fix and + passed afterward; independent review ran 10/10 focused tests and the + buffer suite passes 93/93. Default, seed-`20260729`, and isolated full + suites pass 182/182; C/Elisp fuzz passes 300/300. Warning-as-error + compilation, checkdoc, pinned package-lint, release, dictionary, + pinned-source, no-overlay, no-stale-symbol, and diff gates pass. + Point-motion benchmark p99 is 0.033 ms on the C backend and 0.037 ms + in the independent Elisp-backend review, with zero planner/cache calls + in both. Reviewed 26.6-second GUI evidence at + `/private/tmp/ekp-frontier-live-v3-66WYRW` contains 39 manifest lines, + all staged assertions green, no black segment, and a PASS report. + Architecture review is CLEAR; code review reports zero blockers and + isolates the unrelated append latency as `issue018`/`task030`. + - Closure: task029's developer gate is complete. `issue016` and + `issue017` remain open until the user personally confirms the visible + editing experience. + +- task030 [ ] Diagnose and optimize unique-state live append planning at + very narrow widths. + - Source: `issue018` and + `postmortem/20260729-narrow-live-append-replanning.md`. + - Problem: after task031, the checked-in 80-pixel workload performs zero + planning on ordinary same-row edits but still records 15 structural + boundary plans across 291 appends. A fresh GC-excluded run measured + C median 2.177 ms/p99 51.170 ms and Elisp median 2.176 ms/p99 + 187.499 ms; the high percentiles now belong to permitted commit spikes, + not per-key frontier replanning. + - Work: profile a documented width/length and commit-event matrix, + identify the surviving structural-planning owner of the cost, and + implement the smallest exact optimization that reduces commit latency. + - Constraints: preserve KP output semantics, exact cache identity, + task031's stable transaction/structural-commit contract, source-clean + text properties, zero-work point-only motion, and the existing core + DP/C ABI/schema contracts unless a separately approved architecture + decision changes them. Do not use debounce, stale reuse, skipped edits, + timers, or global GC changes to hide mutator latency. + - Validation: result-equivalence ERT/fuzz, profiler before/after evidence, + repeatable Elisp/C default-GC and GC-excluded width-matrix benchmarks, + complete repository gates, and dynamic GUI typing evidence. + - Dependency: `task031` is complete. Optimize only the surviving + structural-commit path; the obsolete per-keystroke whole-hard-line path + no longer exists. + +- task031 [x] Replace the overloaded live frontier with committed projection, + a dirty edit transaction, and atomic structural commits. + - Source: `issue019`, the user's stable-editing correction, and + `postmortem/20260729-stable-live-transaction.md`. + - Red tests: drive installed before/after-change and command hooks to prove + that ordinary same-row edits perform no whole-hard-line planning, a + middle-line edit preserves unaffected projection anchors, deleting and + reinserting the same space restores the exact + `equal-including-properties` projection, point-only motion across or + outside the active hard line performs zero planning/property writes, and + the next real edit elsewhere commits the prior dirty transaction. + - Work: delete `frontier = natural suffix` ownership. Store the committed + source, plan, line signatures, spans, and owned projection baseline in + the edit transaction. Naturalize only the dirty edit island, preserve + unaffected break anchors, let native soft wrapping absorb local + push/pull, and replan atomically only when input crosses a visual-row + boundary or another structural commit event occurs. + - Commit events: native soft-wrap crossing, hard newline/paragraph end, the + next real source edit outside the dirty island, explicit paragraph + refill, and width/font/layout-context change. Cursor motion is never a + commit event. + - Constraints: keep core DP, `ekp.el`, C ABI, DP schema, and layout-plan + semantics unchanged; never add overlays or source layout characters; + preserve exact source/editor state and fail closed on conflicts. + - Validation: focused RED/GREEN ERT, complete buffer/default/random/ + isolated ERT, C/Elisp fuzz, warning-as-error compile/checkdoc/package/ + release/static gates, a re-profiled live benchmark, and temporal GUI + evidence for stable normal input, middle-line edits, reversible edits, + local word migration, hard completion, and point-only motion. + - Impact: `ekp-buffer.el`, buffer/live benchmark/GUI tests, bilingual + user/developer docs, spec/plan/technical reference, issue/change records, + changelog, and the superseding postmortem. + - Implementation: `ekp-buffer--live-state` owns the committed + source/key/plan/signatures/prefix/spans; `ekp-buffer--live-edit` snapshots + that state plus marker offsets and one dirty island. + `ekp-buffer--start-live-edit` naturalizes only the affected span range, + `ekp-buffer--ordinary-live-edit-finished` restores an exact baseline or + publishes only on a real native-row crossing, and hard/elsewhere/ + refill/context events commit through their existing boundaries. Lazy + reflow excludes the active paragraph so static chunks cannot detach + live-owned spans. No task031 change was made to `ekp.el` or the core DP. + - Verification: focused RED tests first failed for same-row planning, + middle-anchor invalidation, exact reversal, point-motion work, backward + row crossing, and lazy-reflow ownership, then passed after the owning + fixes. Final runs pass buffer 99/99 and default, seed-`20260729`, and + isolated suites 188/188 each; C/Elisp fuzz passes 300/300. Production + and task test files compile with warnings as errors; checkdoc is empty; + pinned package-lint exits 0; release and 49-entry dictionary-manifest + gates pass. The exact pinned-source gate passed earlier in this task + with unchanged dictionary bytes; two redundant final downloads ended in + GitHub transport `early EOF`, not a content mismatch. + - Performance: at 80 px with GC excluded, same-row cache-revisit work + performs zero plans (C p99 1.627 ms; Elisp p99 1.502 ms), point motion + performs zero plan/cache calls (C p99 0.017 ms; Elisp p99 0.015 ms), and + 291 appends contain only 15 permitted structural plans. The remaining + commit spikes are the re-profiled `task030` surface. + - Dynamic evidence: the reviewed 48.95-second fullscreen run at + `/private/tmp/ekp-stable-transaction-final5-2BFryc` contains 55 ordered + checkpoints (11 actions × 5 phases), 17 green assertions at every + checkpoint, one 1434×900 target window, a completed run-end, no black + segment, and a PASS report. It covers middle-row locality, exact + reversal, forward structural crossing, backward deletion stability, + point motion, public yank/undo, resize/context commits, and hard + paragraph completion. + - Closure: developer verification is complete. `issue019` remains open + only for the required user-visible confirmation; `task030` is unblocked. + +- task032 [x] Reduce C-backed resize/reflow latency with exact layout parity. + - Source: `issue020` and the user's observed 60–70 ms C resize latency. + - Baseline first: freeze the current portable C module and build a + same-machine evaluator that separates complete reflow, plan construction, + Emacs/C marshalling, C DP, and projection publication. Exclude startup, + resize debounce, and GC pauses from the mutator measurement. + - Performance gate: an interleaved baseline/candidate width-and-length + matrix must improve both p50 and p95 by at least 20%, keep candidate p95 + at or below 50 ms, and report raw samples rather than a best-of run. + - Correctness gate: every candidate C result must match the frozen C + baseline and Elisp layout exactly; complete ERT, C/Elisp fuzz, + warning-as-error builds, static/release checks, and temporal GUI resize + evidence remain mandatory. + - Constraints: do not weaken cache identity, KP output semantics, the + stable transaction model, or source-clean projection. Do not hide work + with longer debounce, skipped widths, stale results, approximate reuse, + background publication, or global GC changes. + - Stop condition: optimize only the layer proven dominant by the baseline + profile. If C DP is not the owner of most resize time, fix the actual + Elisp/C or projection boundary instead of micro-optimizing the DP. + - Impact: evaluator/benchmark, the proven runtime owner, C module tests, + performance documentation, issue/change records, and a decision + postmortem when the selected optimization is non-obvious. + - Implementation: resolve one paragraph/DP payload per plan, cache natural + gap geometry, omit true no-op gaps, consolidate owned property writes, + and avoid static-plus-live double publication of the active paragraph. + - Result: four interleaved rounds reduce core p50/p95 from + 22.949/42.006 ms to 15.318/27.687 ms and complete resize p50/p95 from + 28.149/46.611 ms to 15.900/27.487 ms. Exact frozen-C/Elisp parity holds. + - Verification: normal and permuted 193-test suites, the per-test isolated + runner, 300-case fuzzing, warning-as-error compilation, package-lint, + checkdoc, release checks, and reviewed dynamic GUI evidence pass. + - Closure: developer verification is complete. `issue020` remains open + only for the required user-visible confirmation. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/tech-refer_text_property_layout_20260729.md b/.phrase/phases/phase-kp-overhaul-20260726/tech-refer_text_property_layout_20260729.md new file mode 100644 index 0000000..0365eeb --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/tech-refer_text_property_layout_20260729.md @@ -0,0 +1,408 @@ +# Technical Reference: Text-Property-Only KP Layout 2026-07-29 + +## Status and Reader Task + +Implemented by `task019` through `task022` for static/source-clean +projection, then refined by later live-editing tasks. `task028` supersedes +the native-row live planning unit with semantic hard-line prefix +projection. This record is the technical reference for the semantic layout +plan, text-property-only buffer projection, live editing boundary, and +their explicit constraints. + +## User Constraints + +- Do not use overlays. +- Do not insert glue spaces, soft newlines, or discretionary hyphens into + buffer text. +- Express layout through text properties on existing source characters. +- Keep the source character sequence and all logical buffer positions + unchanged. +- An unfinished active line and later live-edit behavior remain separate + product concerns; a static display proof does not establish seamless + editing. + +## Conclusion + +Emacs 29.1 and later expose enough display primitives to make a +text-property-only KP renderer plausible: + +- `(min-width ((PIXELS)))` gives a source span an absolute minimum display + width and pads only in the display layer. +- `(space-width FACTOR)` scales existing ASCII space glyphs without + replacing their source characters. +- Combining `space-width` with `min-width` expresses both shrink and stretch + while using `min-width` as an exact pixel floor after floating-point + rounding. +- `line-prefix` supplies indentation for non-continuation display lines. +- A replacing `display` string attached to an existing grapheme can show + that grapheme followed by a discretionary hyphen and a display-only + newline. The original grapheme remains in the buffer. + +These primitives close the representation gap for the implemented +single-authoritative-width buffer renderer. Foreign ownership, multiple +windows, and seamless incremental editing are governed by the decisions +below rather than inferred from the primitives alone. + +The package baseline does not need to move: Emacs `NEWS.29` records +`min-width` as a new `display` property, and the repository's CI exercises +the declared Emacs 29.1 floor. + +## Confirmed Emacs Semantics + +### Absolute pixel minimum width + +The `min-width` display specification has the form +`(min-width (WIDTH))`. The outer one-element list identifies a contiguous +display run by `eq`. A pixel expression of the form `(N)` means exactly +`N` pixels, so the fully nested form for 20 pixels is: + +```elisp +(min-width ((20))) +``` + +Emacs displays the underlying text normally and appends display-only white +space if the rendered span is narrower than the requested minimum. + +Each independently padded adjacent span must receive a distinct identity +list. Reusing the same inner list object can make Emacs treat adjacent +spans as one run and add padding only at the end of the combined run. + +References: + +- [Other Display Specifications](https://www.gnu.org/software/emacs/manual/html_node/elisp/Other-Display-Specs.html) +- [Pixel Specification for Spaces](https://www.gnu.org/software/emacs/manual/html_node/elisp/Pixel-Specification.html) +- [Emacs 30.2 display implementation](https://github.com/emacs-mirror/emacs/blob/emacs-30.2/src/xdisp.c#L5606-L5686) + +### Scaling real spaces + +`(space-width FACTOR)` multiplies the rendered width of every ASCII space +covered by the property. A factor below 1 shrinks; a factor above 1 +stretches. + +The Emacs 30.2 implementation adds three boundaries beyond the manual's +summary: + +- `FACTOR` must be a number greater than zero. +- The graphical display path checks specifically for ASCII `SPC`. +- The specification is ignored on a non-window-system frame. + +It does not affect tabs. NBSP, ideographic spaces, tabs, and other Unicode +space characters therefore need their own policy; they must not be assumed +to behave like an ASCII word space. + +References: + +- [Other Display Specifications](https://www.gnu.org/software/emacs/manual/html_node/elisp/Other-Display-Specs.html) +- [Emacs 30.2 `space-width` parsing](https://github.com/emacs-mirror/emacs/blob/emacs-30.2/src/xdisp.c#L6039-L6052) +- [Emacs 30.2 ASCII-space scaling](https://github.com/emacs-mirror/emacs/blob/emacs-30.2/src/xdisp.c#L32723-L32729) + +### Combining the two specifications + +For a real ASCII whitespace run whose measured natural width is `N` and +whose KP target width is `G > 0`, use the conceptual display value: + +```elisp +((space-width FACTOR) + (min-width ((G)))) +``` + +where `FACTOR = G / N`. + +`space-width` first makes shrink possible. The display engine rounds the +scaled glyph width to pixels; `min-width` then pads any downward rounding +error to the exact target. For a target at least as wide as the natural +run, `min-width` alone is sufficient, but one combined representation can +keep the renderer uniform. + +A zero-width source-space run is not represented by `(space-width 0)`, +because zero is rejected. Line-edge spaces must instead be consumed by the +chosen break representation, retained naturally, or handled by a separately +verified display rule. + +### Indentation + +The `line-prefix` text property adds a display-only prefix to every +non-continuation line. It must cover the complete affected text range so +redisplay can find it at each line start. + +If EKP relies on Emacs's native wrapping, continuation lines use +`wrap-prefix`, not `line-prefix`. If EKP realizes each KP break as a newline +inside a replacing display string, the following row is a non-continuation +display line and `line-prefix` is applicable. + +Reference: + +- [Special Properties: `line-prefix`](https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html) + +## Mapping KP Output to Existing Source Text + +| KP result | Text-property-only projection | +| --- | --- | +| Ordinary box | Display the original source grapheme or word unchanged. | +| Latin word glue over ASCII spaces | Apply `space-width`; add `min-width` as the exact pixel floor. | +| CJK or mixed glue with no source space | Apply `min-width` to the preceding source box with target `natural-box-width + glue-width`. | +| Leading indentation | Apply `line-prefix`; use `wrap-prefix` only if the selected break backend creates continuation lines. | +| Break at an existing single space | Replace that space on display with a newline while leaving the source space in the buffer. | +| CJK break with no boundary character | Make the preceding grapheme display as `GRAPHEME + NEWLINE`. | +| Latin discretionary hyphen break | Make the preceding grapheme display as `GRAPHEME + HYPHEN + NEWLINE`. | +| Final line | Remove forced-break/hyphen properties and leave it ragged unless alignment requires a leading prefix. | + +The current algorithm already computes exact per-gap pixel results for +stretch and shrink. The planned change is a renderer substitution, not a +new KP cost or distribution model. + +## Discretionary Latin Hyphen Without Source Pollution + +### Proposed break owner + +For a KP break between source positions `P` and `P+1` inside a Latin word: + +1. Identify the complete grapheme immediately before the break. Never split + a combining sequence. +2. Leave every source character untouched. +3. Attach a replacing `display` text property to that grapheme. +4. Build the replacement string from the same visible grapheme, followed by + the configured hyphen glyph and `"\n"`. +5. Copy the grapheme's relevant face/font properties to the replacement + string, but remove recursive `display` state. +6. Put a `cursor` property on the reproduced grapheme so point on the + covered source positions is drawn on the grapheme rather than on the + synthetic hyphen or newline. +7. Include the hyphen width in the KP line measurement exactly as the + current renderer already does. +8. Remove or move the property atomically when a later plan chooses a + different break. + +Conceptually, breaking `abcdefgh` after `d` displays: + +```text +abcd- +efgh +``` + +while the source characters remain exactly: + +```text +abcdefgh +``` + +The hyphen and newline exist only inside the `display` property's +replacement string. `char-after`, character positions, regexp search, and +`buffer-substring-no-properties` continue to operate on the original word. + +### Why the preceding grapheme owns the replacement + +Attaching `"-\nE"` to the next grapheme would make point on that source +grapheme default to the hyphen or newline portion. Attaching +`"D-\n"` to the preceding grapheme keeps the visible source glyph at the +property's logical anchor, and the next source grapheme begins naturally on +the following display line. + +### Runtime evidence on Emacs 30.2 + +A clean GUI Emacs 30.2 probe applied a `display` text property containing +`"d-\n"` with `cursor 1` to the source `d` in `abcdefgh`. + +Observed result: + +- `substring-no-properties (buffer-string)` remained `"abcdefgh"`. +- `buffer-size` remained 8. +- `count-screen-lines` reported 2. +- `vertical-motion` by one display line moved point from position 1 to + position 5, the source `e`. +- A 20px `line-prefix` placed both visual rows at x=20. + +This proves the minimal LTR shape is expressible. It does not yet prove +mouse selection, active-region highlighting, isearch faces, bidi text, +multi-codepoint graphemes, IME composition, or foreign display-property +composition. + +References: + +- [Display strings](https://www.gnu.org/software/emacs/manual/html_node/elisp/Other-Display-Specs.html) +- [Cursor placement in display strings](https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html) +- [Emacs 30.2 cursor handling around display-string newlines](https://github.com/emacs-mirror/emacs/blob/emacs-30.2/src/xdisp.c#L1899-L1956) + +## Runtime Evidence for Exact Glue + +A clean GUI Emacs 30.2 probe measured the current font's ASCII space at +7px, then applied the combined `space-width` and `min-width` display value +to two real source spaces: + +- target 3px rendered as exactly 3px; +- target 20px rendered as exactly 20px; +- the buffer still returned the unchanged source characters `"a b"`. + +This proves one-font, one-window shrink and stretch. The production gate +must repeat the probe across text scaling, face remapping, font fallback, +mixed property runs, and window resizing. + +## Text API Boundary + +This direction removes character pollution but not all property metadata. + +- `buffer-substring-no-properties`, direct character access, search, + syntax parsing, markers, point, and character counts see the original + source characters. +- `buffer-string` and `buffer-substring` copy text properties into the + returned string. Lisp that explicitly examines properties can observe + EKP's display metadata. +- Property-only updates must use `with-silent-modifications` so layout + refresh does not alter the modified flag, ordinary undo history, or + external change hooks. +- EKP-owned properties must be nonsticky so new user input does not inherit + a stale break, hyphen, width, or prefix. +- Copy/yank and insertion of propertized strings require an ownership rule + that strips or regenerates EKP layout properties rather than carrying a + stale layout into another location. + +References: + +- [Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Text-Properties.html) +- [Examining Buffer Contents](https://www.gnu.org/software/emacs/manual/html_node/elisp/Buffer-Contents.html) +- [Changing Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Changing-Properties.html) + +## Implementation Decisions and Remaining Boundaries + +### Chosen break semantics + +The replacement-string approach forces the exact KP break and makes +`line-prefix` usable. A native-wrap alternative might avoid replacing the +break-owner grapheme by filling the line exactly with `min-width`, but it +still needs proof that Emacs never wraps at an earlier legal boundary and +that exact-edge stretch glyphs remain on the intended line. + +The implementation uses the replacing-string backend because it represents +the chosen KP break explicitly. Native wrapping is not a fallback. + +### Foreign `display` ownership + +Text has one effective `display` property value at each position. Parallel +non-replacing specifications can coexist in one value, but a foreign +replacing string, image, invisibility rule, composition, or font-lock +manager can conflict with EKP's width or break owner. + +EKP rejects the affected hard paragraph with an explicit diagnostic. It +never erases, merges, or silently reorders a foreign replacing +specification. + +### Graphemes, bidi, and shaping + +A replacing display string is treated as one display object for +bidirectional reordering. The first implementation slice should therefore +target LTR Latin hyphenation and CJK. Arabic, Hebrew, Indic shaping, +ligatures, and multi-codepoint graphemes remain gated until their cursor, +selection, and shaping behavior is proven. + +### Multiple windows + +Text properties belong to the buffer, not a window. One property set cannot +simultaneously encode two different KP break plans for the same buffer shown +at different widths. + +The narrowest live window showing the buffer is authoritative. Wider +windows can retain right-side space but cannot overflow. Independent +simultaneous width plans are explicitly outside this text-property-only +architecture. + +### Live editing + +The current target policy separates stable editing from global KP +commitment. One committed projection owns the hard-line baseline source, +semantic plan, line signatures, projected spans, and break anchors. One +dirty edit transaction snapshots that baseline and naturalizes only the +affected local island. + +Ordinary input inside the same native visual row performs no whole-hard-line +planning. Existing glue and native soft wrapping absorb local changes while +unaffected anchors remain installed. If the row overflows, native wrapping +moves only the required local words. Crossing into a new native visual row +is a structural commit: `ekp-buffer` calls or reuses the existing +whole-hard-line `ekp-layout-plan` once, then old/new semantic signatures +limit one silent prefix publication. + +Hard newline/paragraph completion, the next real edit outside the dirty +island, explicit refill, and width/font/layout-context changes are the other +commit events. Point-only motion is not a layout event anywhere. A reversible +edit restores the saved owned projection directly and +`equal-including-properties`. + +This remains a buffer-layer policy, not a DP variant. Core DP plans complete +text and receives no point, marker, window, buffer, redisplay, transaction, +or composition state. The C ABI, DP schema, and plan contract do not change. + +Static paragraph-edge hiding applies only to committed projected lines. The +dirty edit island retains leading/trailing source whitespace with no +replacing display, so a single space or tab is visible in the same +command-loop turn. Reprojection restores the mark marker and `mark-active` +as separate editor state; `set-mark` is not a valid positional restoration +API because it activates an otherwise inactive mark. + +Automatic planning is bounded by +`ekp-auto-justify-paragraph-limit`. An oversized single hard line stays +natural and diagnostic rather than blocking input; the explicit +`ekp-refill-paragraph` command bypasses this limit. + +## Rejected Shortcuts + +- Insert U+00AD, `-`, whitespace, or newline characters: violates the + logical-text requirement. +- Overlay `before-string` or `after-string`: explicitly excluded by the + user. +- Use `min-width` alone for shrink: it only adds padding. +- Use `space-width` alone as an exact-width guarantee: floating-point + scaling is rounded to pixels. +- Treat tabs or all Unicode whitespace as ASCII spaces: contradicted by the + documented and source-level behavior. +- Claim the GUI probe proves seamless editing: it covers a static LTR + display case only. + +## Runtime Verification Gate + +1. Exact glue: + - 1px through 64px targets; + - shrink and stretch; + - CJK, ASCII space, mixed gap, and repeated spaces; + - text scaling, face remap, and fallback fonts. +2. Breaks and hyphens: + - Latin word break with hyphen; + - CJK break without whitespace; + - break at one and multiple source spaces; + - final-line removal and breakpoint movement. +3. Editor semantics: + - point at every source position; + - `C-n`/`C-p`, visual beginning/end of line, mouse hit testing; + - active region, isearch, kill/yank, undo/redo, and narrowing. +4. Ownership: + - font-lock refontification; + - a foreign replacing `display` property; + - composition and IME preedit; + - exact restoration of pre-existing properties. +5. State invariants: + - unchanged source characters and `buffer-chars-modified-tick`; + - unchanged modified flag, undo list, and external change-hook count; + - no stale property inheritance after insert, delete, split, or join. +6. Architecture decision: + - explicitly resolve or relax simultaneous different-width windows + before calling the renderer complete. + +## Stop Condition + +This research task is complete when the confirmed primitives, proposed +hyphen owner, known limitations, and runtime evidence are recorded. +The original static feasibility and one-buffer/multiple-window gates were +satisfied by `task019`–`task022`. + +`task028` proved whole-hard-line plan consumption and semantic signature +publication. `task029` proved that point-only motion must perform zero work. +`issue019`/`task031` supersede their per-edit trigger ownership: a frontier +cannot simultaneously be latest edit, natural suffix, and projection reuse +boundary. Whole-hard-line planning remains valid at structural commits, not +after every key. + +The task029 performance audit recorded 291 unique append plans and zero cache +hits at 80 pixels. `task031` now removes ordinary same-row planning: the same +291-edit workload records 15 structural plans, while same-row revisit and +point-motion scenarios record zero plans. The remaining C/Elisp p99 spikes +coincide with permitted commit events; `issue018`/`task030` now own that +unblocked, narrower performance surface. diff --git a/CHANGELOG.md b/CHANGELOG.md index b5255a5..238d8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ project aims to follow [Semantic Versioning](https://semver.org/). ### Fixed +- C-backed core layout and complete resize/reflow now reuse prepared + paragraph, DP, and gap geometry and avoid duplicate projection + publication. The four-round same-machine gate records p95 values of + 27.687 ms and 27.487 ms respectively, with exact frozen-C/Elisp parity. +- Reprojecting a buffer now preserves the mark marker and `mark-active` + independently. Showcase width keys and resize reflow no longer turn an + old inactive mark into a highlighted region. +- The unfinished live line now keeps its source-edge whitespace natural. + A leading/trailing space or tab appears in the same input turn, and + whitespace exposed by backspace is no longer hidden by `display ""` + until another glyph arrives. +- Enabling or disabling a theme, or changing a frame's default font, now + invalidates live plan history and reflows every active auto-justify + buffer with the new glyph metrics. - Changing any Knuth-Plass cost parameter now selects a correctly keyed DP/render result immediately; cached paragraphs no longer require `ekp-clear-caches`. Structurally equal non-zero-looseness signatures now @@ -15,14 +29,21 @@ project aims to follow [Semantic Versioning](https://semver.org/). - Changing `ekp-default-cws-stretch-pixel` in automatic spacing mode now invalidates both paragraph-cache lookup paths immediately. Unchanged spacing signatures still reuse the cached paragraph. -- Saving justified buffers now writes from a logical-text copy instead of - temporarily unformatting the display buffer. Filesystem, encoding, and - interruption failures therefore leave the visible layout intact and - retryable. +- Buffer layout no longer rewrites the source character stream. Saving, + region writes, direct Elisp reads, syntax, and search therefore operate + on logical text without a temporary serialization copy. +- Live editing now separates committed projection from a local dirty edit + transaction. Same-row typing performs no whole-hard-line planning, + middle-row edits preserve unaffected break anchors, and exact source + reversal restores the saved projection `equal-including-properties`. +- Point-only motion no longer changes live state or projection anywhere, + including after leaving the active hard paragraph. Structural commits are + owned by visual-row crossing, hard completion, the next real edit + elsewhere, explicit refill, and width/font/layout-context changes. - Kill/copy integration now composes with and restores an existing - `filter-buffer-substring-function`, including DELETE operations. Removing - the final layout span outside auto mode also removes unused save/search/ - copy integrations. + `filter-buffer-substring-function`, including DELETE operations, and + strips only EKP-owned projection properties from copied text. Removing + the final layout span outside auto mode restores the exact prior filter. - Dictionary syntax no longer degrades silently: files containing libhyphen replacement/slash patterns fail with an explicit condition because the fixed-width DP cannot model their conditional rewrites. @@ -32,6 +53,41 @@ project aims to follow [Semantic Versioning](https://semver.org/). ### Changed +- The core now exposes semantic `ekp-layout-plan`, `ekp-layout-line`, and + `ekp-layout-gap` records. The compatible string renderer and the buffer + renderer consume the same break, glue, indentation, and discretionary- + hyphen decisions. +- Buffer/region layout is now a pure text-property projection on existing + source characters: ASCII spaces combine `space-width` and absolute-pixel + `min-width`; zero-source CJK/mixed glue uses `min-width` on a complete + grapheme; indentation uses `line-prefix`; and visual breaks/hyphens use + replacing display strings. EKP creates no overlay and inserts no layout + character into a buffer. +- Auto-justify now uses stable hard-line transactions. A dirty island stays + native between commit events, so ordinary keys neither run DP nor rewrite + unrelated properties. Native visual-row crossing atomically republishes + every completed row from the unchanged whole-hard-line core plan; the new + row remains natural. A buffer-local 16-entry LRU reuses recent + text/context plans, and common-prefix signatures minimize writes at + commits. IME and resize publication remain generation-checked; no + edit-idle or cursor-motion formatter exists. +- Auto-justify now owns its native soft-wrap precondition. It temporarily + disables `truncate-lines` and narrow partial-window truncation, including + Emacs's default sub-50-column behavior, then restores the prior values and + buffer-local ownership on teardown. +- One buffer uses the narrowest displayed window as its authoritative + width. Foreign replacing display owners and unsupported non-ASCII + whitespace shrink keep only the affected hard paragraph natural and are + reported by `ekp-diagnose`. +- Automatic work bounds a single hard paragraph with + `ekp-auto-justify-paragraph-limit` (default 2,048 characters). Longer + paragraphs remain naturally editable; `ekp-refill-paragraph` is the + explicit unbounded quality pass. +- **Breaking:** the editor integration module is now `ekp-buffer.el` and + provides `ekp-buffer`; replace `(require 'ekp-region)` with + `(require 'ekp-buffer)`. Module-owned configuration names now use the + `ekp-buffer-` prefix. Public commands whose names describe an actual + region or buffer operand are unchanged. - The bundled dictionary inventory is now 49 reproducible entries with a pinned LibreOffice commit, per-file SHA-256/source/license manifest, and a portable verifier/exporter. Sanskrit was removed because the pinned @@ -44,6 +100,21 @@ project aims to follow [Semantic Versioning](https://semver.org/). ### Tests +- ERT now covers source/tick/undo invariants, exact ASCII and CJK glue + projection, display-only static breaks and hyphens, zero-projection + underfilled input, whole-hard-line semantic-prefix selection, non-frozen + earlier breaks, common-prefix differential writes, plan-cache reuse, + point-motion projection stability, stable dirty transactions, exact + reversible restoration, atomic visual-row commits, hard-paragraph + completion, IME/stale generations, foreign ownership, + narrowest-window policy, inactive/active mark preservation, immediate + edge whitespace, consecutive spaces, newline/yank/real-undo paths, and + the oversized-paragraph guard. + GUI probes verify pixel widths. Reviewed temporal recordings verify + width-key selection and immediate edge-whitespace states, plus stable + middle-row edits, exact reversible restoration, atomic visual-row + commits, zero-work point motion, public yank/undo, resize commits, and + hard-paragraph completion without delayed snap or overlays. - ERT fixtures now restore every mutable EKP option they isolate. The parshape C-bypass regression drives the public formatter, and reusable permuted-order plus per-test fresh-process runners prevent alphabetical @@ -57,6 +128,14 @@ project aims to follow [Semantic Versioning](https://semver.org/). ### Performance +- The live-path benchmark records append, historical-plan reuse, + point-motion, and hard-boundary latency with plan/cache counters. + At the synthetic 80-pixel width, same-row editing and point motion perform + zero planning; point-motion p99 is 0.017 ms on C and 0.015 ms on Elisp. + Across 291 appends, only 15 visual-row crossings invoke the planner. + Those structural commits still exceed the 16 ms p99 frame budget on both + backends and are tracked as `issue018`/`task030`; no stale reuse, debounce, + skipped publication, or global GC workaround hides the miss. - Tokenization now accumulates fragments and joins once per emitted box; dense hyphen insertion likewise joins original word slices once. On the 1,000–8,000-character adversarial benchmark, the 8,000-character cases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index debad7e..53a6558 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ EMACS=emacs # or /path/to/Emacs # Byte-compile with warnings as errors (CI does this) $EMACS -Q --batch -L . \ --eval '(setq byte-compile-error-on-warn t)' \ - -f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el + -f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el ekp-buffer.el # Run the ERT suite (C-module tests auto-skip if not built) tests/run-tests.sh $EMACS @@ -36,21 +36,23 @@ dictionaries/update.sh check to the demerits or line-metric formulas must touch both `ekp--dp-run-1d` (Elisp) and `dp_process_position` (`ekp_c/ekp_kp.c`), and the fuzz suite must stay at 300/300. -- **The layout is lossless.** The renderer's marker properties - (`ekp-glue`, `ekp-soft-break`, `ekp-soft-hyphen`, `ekp-hidden`) must - round-trip exactly through `ekp-unjustify-region`. +- **The two renderers have different rights.** The string renderer's + physical marker vocabulary (`ekp-glue`, `ekp-soft-break`, + `ekp-soft-hyphen`, `ekp-hidden`) stays lossless and compatible. The + buffer renderer must create no overlay or source character and may use + only EKP-owned text properties on existing characters. - Any C-module API change bumps `EKP_VERSION_MINOR` and the matching `ekp-c-module-required-version`, and rebuilds the module. - New behavior needs an ERT test. Buffer-level behavior (save, isearch, undo, kill/yank, mode interactions) goes in - `tests/ekp-region-tests.el`. + `tests/ekp-buffer-tests.el`. ## Style - `lexical-binding: t` everywhere; keep byte-compilation warning-free. - `checkdoc` clean (CI enforces it): imperative docstring first lines, arguments mentioned in uppercase, two spaces after a sentence. -- `package-lint` clean: the `ekp-` / `ekp-region-` namespaces, proper +- `package-lint` clean: the `ekp-` / `ekp-buffer-` namespaces, proper autoload cookies on interactive entry points. - Match the surrounding code; keep comments about *why*, not *what*. diff --git a/DEVELOPER.md b/DEVELOPER.md index b790857..e5979ea 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -153,52 +153,127 @@ optimality. Both engines implement the identical strategy. - Last lines are ragged-right (ideal glues + trailing filler); single-box lines get a trailing filler clamped at ≥ 0. -`ekp--pixel-justify` then strips leading space boxes (except on the -first line — indentation) and trailing space boxes, and appends a -hyphen — propertized like the word it breaks — where a line ends at a -hyphenation point. Stripped widths are *not* redistributed: the DP -already excluded them (§3). +`ekp-layout-plan` is the representation boundary between layout and +rendering. It combines the paragraph boxes/source offsets, DP breaks, +per-line glue targets, indentation, stripped edges, and discretionary +hyphen decisions into `ekp-layout-plan`, `ekp-layout-line`, and +`ekp-layout-gap` records. The plan contains no buffer positions or display +mechanism. `ekp-render-layout-string` consumes it for the public string API; +the buffer integration can consume the same decisions without re-running or +reinterpreting the KP algorithm. -Glues become `(space :width (N))` display properties, so justification -is pixel-exact in GUI Emacs and column-exact in batch/tty. +The two consumers deliberately have different representation rights. -The output is **lossless**: boxes are located in the source string -(`ekp--box-offsets`), and every synthesized or hidden piece records the -original text it stands for — +#### String renderer -| property | on | value / meaning | -|-------------------|-----------------------|--------------------------------| -| `ekp-glue` | synthesized glue space| original text it replaced | -| `ekp-soft-break` | inserted `\n` | whitespace swallowed at break | -| `ekp-soft-hyphen` | inserted hyphen | marker only | -| `ekp-hidden` | paragraph-edge text | kept verbatim, `display ""` | +`ekp-render-layout-string` preserves the public string API. It strips +leading/trailing space boxes, synthesizes display spaces and visual +newlines, and appends a propertized discretionary hyphen where selected. +The returned string remains lossless through four private markers: -Zero-width glue with a non-empty original renders as the hidden -original itself, so no character is ever dropped. `ekp-region.el` -inverts these four structurally (`ekp-unjustify-region`) — exact even -after the justified text was edited — and builds -`ekp-justify-region` / `ekp-auto-justify-mode` on top. -`ekp--layout-marker-properties` owns the complete renderer/region marker -vocabulary and its non-inheritance contract. +| property | on | value / meaning | +|-------------------|------------------------|-------------------------------| +| `ekp-glue` | synthesized glue space | original text it replaced | +| `ekp-soft-break` | synthesized `\n` | swallowed boundary whitespace | +| `ekp-soft-hyphen` | synthesized hyphen | marker only | +| `ekp-hidden` | paragraph-edge text | source retained, display empty| -Saving is a non-mutating serialization boundary. -`ekp-region--write-logical-buffer` runs first in the buffer-local -`write-region-annotate-functions`, switches whole-buffer writes to a hidden -logical copy, and leaves the display buffer untouched. Subsequent annotation -functions and coding conversion operate on that copy. A successful write -disposes it immediately; a failed write keeps at most one copy, which the -next write or integration teardown replaces. Region-only `write-region` -calls intentionally retain Emacs's physical-buffer semantics; the logical -serialization boundary is the whole-buffer save path. +This physical representation exists only in the returned string. It is +kept for API compatibility and is not installed into a source buffer. +`ekp--layout-marker-properties` owns its marker vocabulary and +non-inheritance contract. -Copy filtering has explicit single-slot ownership. EKP records whether the -previous `filter-buffer-substring-function` was local, temporarily restores -that value, and invokes the public `filter-buffer-substring` dispatcher to -preserve its transform and DELETE behavior. EKP then structurally removes -layout markers from the returned string. DELETE lifecycle cleanup runs after -the temporary binding is unwound, so removing the final justified span -outside auto mode restores that exact local value or reveals the inherited -value, and removes the save/search/change hooks. +#### Buffer renderer + +`ekp-buffer.el` keeps the buffer's character sequence untouched and +projects the same plan with text properties on existing source characters: + +- source ASCII spaces: + `((space-width FACTOR) (min-width ((TARGET-PIXELS))))`; +- zero-source CJK/mixed glue: `min-width` on the preceding complete + grapheme, targeting its natural advance plus glue; +- indentation: `line-prefix`; +- a source-whitespace break: the first boundary character displays as + newline and the rest as empty; +- a CJK or discretionary-hyphen break: a replacing display string + reproduces the existing complete grapheme, appends the optional hyphen, + then a visual newline. + +`ekp-buffer--display` and `ekp-buffer--line-prefix` record exact ownership. +Removal clears the public property only when its value is still identical +to EKP's owner value, so a later foreign change is not erased. Paragraphs +with foreign `display`, `line-prefix`, `wrap-prefix`, `composition`, or +`invisible` ownership stay verbatim. Exact shrink of tabs/non-ASCII +whitespace is also refused because `space-width` affects ASCII spaces only. + +All installation/removal runs inside `with-silent-modifications`, and owned +properties are nonsticky. Buffer characters, point/mark, modified state, +undo, character-modified tick, and external change hooks therefore remain +source-owned. Reprojection restores the mark marker without calling +`set-mark`, then restores `mark-active` independently; an inactive mark +cannot become a region as a layout side effect. No EKP buffer path creates +an overlay. + +Saving, ordinary search, syntax, and direct Elisp character APIs need no +logical-text adapter: the real buffer is already logical. Copy filtering +remains necessary because `buffer-substring` intentionally preserves text +properties. EKP composes with the previous +`filter-buffer-substring-function`, then removes only its projection +metadata from the copied string. + +#### Live flow + +Live editing uses the ordinary whole-text Knuth-Plass plan without giving +editing state to the core planner: + +1. Live state owns the last committed hard-line source, normal + `ekp-layout-plan`, semantic signatures, projected spans, and stable break + anchors at the narrowest-window authoritative width. +2. The first real change opens one edit transaction. It snapshots that + committed state, then removes EKP properties only from the smallest + projected span range containing the edit. Span objects and unaffected + anchors remain registered. +3. Further changes inside the dirty island use native soft wrapping and do + no whole-hard-line planning. If the logical source returns exactly to the + snapshot, EKP restores the saved owned-property runs and marker offsets + directly; the state, plan, signatures, and spans retain object identity. +4. Crossing a native visual-row boundary is a commit. EKP computes or reuses + one whole-hard-line plan, derives every completed semantic row, and + publishes their changed suffix silently as one command-loop transition. + The new current row remains natural. +5. The other commits are a hard newline/paragraph completion, the next real + edit outside the dirty island, explicit refill, and width/font/face/theme + or layout-context change. Point motion is never a commit, even across hard + paragraphs; there is no live `post-command-hook`. +6. Stable line signatures minimize property writes at a commit, while the + 16-entry buffer-local LRU reuses recent text/context plans. Neither + mechanism decides when layout is allowed to change; the transaction owns + that policy. +7. IME preedit, foreign display ownership, unsupported shrink, oversized + hard lines, stale generations, or publication errors fail closed to + native display. A partial projection is rolled back and the original + error is surfaced. + +Native wrapping is a state-machine precondition, not a user preference the +mode can merely hope is enabled. On activation, the mode snapshots the +values and local-binding ownership of `truncate-lines` and +`truncate-partial-width-windows`, then makes both buffer-local and nil. +Teardown restores local values or removes the temporary bindings so global +ownership resumes. This prevents Emacs's default 50-column partial-window +threshold from silently turning a narrow split into horizontal scrolling. + +This state model needs no live lookahead, push/pull convergence, per-key +whole-line planner, or idle formatter. A real edit after point motion may +commit the previous active hard line, including when narrowing makes that +line inaccessible; point motion itself remains a strict no-op. + +No edit-idle whole-paragraph formatter exists. Resize/background work is +generation-owned. Large buffers are processed visible-first in hard- +paragraph chunks; a single hard paragraph above +`ekp-auto-justify-paragraph-limit` remains natural during automatic work +and requires explicit `ekp-refill-paragraph` for an unbounded quality pass. +Because text properties are buffer-wide, the narrowest live window supplies +the one authoritative width. ### 5.1 Break permissions, alignment, protrusion, shapes @@ -403,11 +478,11 @@ ekp.el Core: para struct, caching, DP (1D + looseness), ekp-utils.el Tokenizer (boxes, kinsoku), font detection with batch/tty fallbacks, C module loading ekp-hyphen.el Liang hyphenation + dictionary registry -ekp-region.el Buffer/region commands, ekp-auto-justify-mode, and - editor integration (save, isearch, kill-ring, undo) +ekp-buffer.el Text-property-only buffer/region projection, synchronous + live flow, window lifecycle, copy filtering, diagnostics ekp_c/ C dynamic module (see ekp_c/README.md) dictionaries/ Hunspell hyphenation patterns (from LibreOffice) -tests/ ekp-tests.el, ekp-region-tests.el (ERT), +tests/ ekp-tests.el, ekp-buffer-tests.el (ERT), ekp-fuzz.el (parity fuzz), ekp-bench.el, ekp-demo.el, ekp-showcase.el, sample texts, run-tests.sh diff --git a/DEVELOPER_ZH.md b/DEVELOPER_ZH.md index 4a2d8c7..1cec238 100644 --- a/DEVELOPER_ZH.md +++ b/DEVELOPER_ZH.md @@ -136,46 +136,106 @@ penalty/flagged 断点),主流程无 `q`/looseness(见 §6),相邻松紧惩 ≥ 0。 - 末行右侧不齐(理想 glue + 尾部填充);单盒行的尾部填充钳制 ≥ 0。 -`ekp--pixel-justify` 随后剥离行首空格盒(首行除外——缩进)与行尾空格 -盒,在断词处附加连字符(继承所断单词的文本属性)。剥离的宽度**不再** -重新分配:DP 已经排除了它们(§3)。 +`ekp-layout-plan` 是布局决策与渲染方式之间的表示边界。它把段落盒子 +及其原文 offset、DP 断点、逐行 glue 目标、缩进、边缘剥离和可选断词 +决策组合成 `ekp-layout-plan`、`ekp-layout-line` 和 `ekp-layout-gap` +记录。plan 不包含 buffer 位置或具体显示机制。 +`ekp-render-layout-string` 用它服务现有字符串 API;buffer 集成可复用 +完全相同的决策,无需重新运行或重新解释 KP 算法。 -Glue 渲染为 `(space :width (N))` display 属性,GUI 下像素级精确, -batch/tty 下按字符列精确。 +两个消费方拥有不同且明确的表示权限。 -渲染输出是**无损**的:先用 `ekp--box-offsets` 在原串中定位每个盒子, -然后每一处合成/隐藏内容都记录它所对应的原文—— +#### 字符串渲染器 -| 属性 | 位置 | 值 / 含义 | -|-------------------|-----------------|----------------------------| -| `ekp-glue` | 合成的 glue 空格| 它所替换的原文 | -| `ekp-soft-break` | 插入的 `\n` | 断点处被吞掉的空白 | -| `ekp-soft-hyphen` | 插入的连字符 | 仅作标记 | -| `ekp-hidden` | 段落边缘文本 | 原样保留,`display ""` 隐藏| +`ekp-render-layout-string` 保持公开字符串 API 兼容。它剥离首尾空格 +盒、合成 display 空格与视觉换行,并在选中断词点附加继承样式的连字符。 +返回字符串通过四类私有标记保持无损: -零宽 glue 若对应非空原文,直接渲染为隐藏的原文本身,因此任何字符都 -不会丢失。`ekp-region.el` 对这四类标记做纯结构逆变换 -(`ekp-unjustify-region`)——即使排版后又被编辑过也能精确还原——并在 -其上实现 `ekp-justify-region` / `ekp-auto-justify-mode`。 -`ekp--layout-marker-properties` 统一拥有 renderer/region 的完整标记 -词汇表及其不向新输入继承的契约。 +| 属性 | 位置 | 值 / 含义 | +|-------------------|------------------|---------------------------| +| `ekp-glue` | 合成的 glue 空格 | 它所替换的原文 | +| `ekp-soft-break` | 合成的 `\n` | 断点处吞掉的边界空白 | +| `ekp-soft-hyphen` | 合成的连字符 | 仅作标记 | +| `ekp-hidden` | 段落边缘文本 | 保留源文本、显示为空 | -保存是非修改式序列化边界。buffer-local -`write-region-annotate-functions` 中最先运行 -`ekp-region--write-logical-buffer`,把整 buffer 写入切换到隐藏的逻辑 -副本,显示 buffer 始终不变;后续 annotation 与编码转换继续处理该副本。 -成功写入立即销毁副本;失败时每个源 buffer 最多保留一份,下次写入或 -integration teardown 会替换并清理它。只写局部的 `write-region` -有意保留 Emacs 的物理 buffer 语义;逻辑序列化边界只覆盖整 buffer -保存路径。 +这份物理表示只存在于返回字符串中,用于兼容既有 API,绝不会安装到源 +buffer。`ekp--layout-marker-properties` 统一拥有其标记词汇表与不继承 +契约。 -复制过滤有明确的单槽 owner。EKP 记录原 -`filter-buffer-substring-function` 是否为 buffer-local,临时恢复该值 -并调用公开的 `filter-buffer-substring` dispatcher,以保留转换与 -DELETE 语义,再从返回字符串中结构化移除 EKP 布局标记。DELETE 的 -lifecycle 清理在临时绑定解除后执行;auto mode 外最后一个排版区间 -消失时,会恢复原 local 值或重新暴露继承值,同时移除 -save/search/change hooks。 +#### Buffer 渲染器 + +`ekp-buffer.el` 保持 buffer 字符序列不变,只在现有源字符上用文本属性 +投影同一份 plan: + +- 源 ASCII 空格: + `((space-width FACTOR) (min-width ((TARGET-PIXELS))))`; +- 无源空格的 CJK/混排 glue:把 `min-width` 加在前一个完整字素上, + 目标为其自然 advance 加 glue; +- 缩进:`line-prefix`; +- 源空白断点:第一个边界字符显示成换行,其余显示为空; +- CJK 或拉丁断词断点:replacing display string 重现已有完整字素, + 接上可选连字符和视觉换行。 + +`ekp-buffer--display` 与 `ekp-buffer--line-prefix` 记录精确 owner。 +移除时只有公开属性的值仍与 EKP owner 值相同时才清除,因此后来的外部 +修改不会被误删。带外部 `display`、`line-prefix`、`wrap-prefix`、 +`composition` 或 `invisible` owner 的段落保持 verbatim。若精确排版 +要求缩窄 tab/非 ASCII 空白也会拒绝,因为 `space-width` 只影响 ASCII +空格。 + +所有安装/移除都在 `with-silent-modifications` 内完成,自有属性设为 +nonsticky。因此 buffer 字符、point/mark、modified 状态、undo、字符 +修改 tick 与外部 change hook 仍完全由源编辑拥有。任何 EKP buffer +路径都不会创建 overlay。重投影直接恢复 mark marker,不会调用会激活 +选区的 `set-mark`,随后独立恢复 `mark-active`;因此布局不能把旧 mark +变成选区。 + +保存、普通搜索、语法和直接 Elisp 字符 API 不再需要逻辑文本 adapter: +真实 buffer 本来就是逻辑文本。复制过滤仍有必要,因为 +`buffer-substring` 按设计保留文本属性。EKP 先组合原有 +`filter-buffer-substring-function`,再只移除自己在复制字符串中的投影 +元数据。 + +#### 实时流式排版 + +实时编辑直接消费正常的整段 Knuth-Plass plan,不会把编辑状态交给 +核心规划器: + +1. live state 按最窄窗口权威宽度持有最后一次提交的硬行源文本、正常 + `ekp-layout-plan`、语义签名、投影 span 与稳定断行锚点。 +2. 第一次真实变更打开一个编辑事务。事务先快照提交态,再只移除包含该 + 编辑的最小投影 span 范围中的 EKP 属性;span 对象与无关锚点仍保持 + 注册。 +3. 脏岛内的后续变更交给原生软折行,不规划整条硬行。若逻辑源文本精确 + 回到快照,EKP 直接恢复保存的自有属性区间与 marker 偏移;state、plan、 + signatures 和 spans 都保持对象身份。 +4. 跨过原生视觉行边界才提交。EKP 计算或复用一次整条硬行 plan,得到 + 全部已完成语义行,并在同一个 command-loop 转换中静默发布真正变化的 + 后缀;新的当前行保持自然。 +5. 其他提交事件只有硬换行/段落完成、下一次真实编辑发生在脏岛之外、 + 显式 refill,以及宽度/字体/face/主题或布局上下文变化。point 移动永远 + 不是提交,即使跨越硬段落也一样;live 路径没有 `post-command-hook`。 +6. 稳定行签名只负责在提交时减少属性写入,buffer-local 16 项 LRU 只负责 + 复用近期文本/上下文 plan。何时允许布局变化由事务控制,不是二者控制。 +7. IME preedit、外部显示所有权、不支持的收缩、超长硬行、过期 + generation 或发布错误都会 fail closed 到原生显示。半成品投影会 + 回滚,原始错误继续向外报告。 + +原生软折行是状态机的前提,不能只希望用户碰巧开启它。mode 启用时先 +保存 `truncate-lines` 和 `truncate-partial-width-windows` 的值与 +buffer-local 所有权,再把二者设为 buffer-local `nil`;teardown 时恢复 +原局部值,或移除临时局部绑定让全局值重新接管。这样 Emacs 默认的 +50 列分栏阈值就不会悄悄把窄分栏变成横向滚动。 + +这个状态模型不需要 live lookahead、push/pull 收敛、逐键整行规划器或 +idle formatter。point 移动本身严格零副作用;之后若在别处发生真实编辑, +则允许提交先前活动硬行,即使 narrowing 让该行已不在可访问区内。 + +不存在编辑空闲后整段 formatter。resize/后台工作都带 generation。 +大 buffer 按可见优先的硬段落 chunk 处理;单个硬段落超过 +`ekp-auto-justify-paragraph-limit` 时,自动路径保持自然显示,只有显式 +`ekp-refill-paragraph` 才执行无界完整质量 pass。文本属性属于 buffer, +所以以最窄活动窗口作为唯一权威宽度。 ### 5.1 断行许可、对齐、悬挂、段形 @@ -346,11 +406,11 @@ ekp.el 核心:para 结构、缓存、DP(1D + looseness)、 ekp-utils.el 分词器(盒子、避头尾)、带 batch/tty 回退的字体 检测、C 模块加载 ekp-hyphen.el Liang 断词 + 词典注册 -ekp-region.el buffer/region 命令、ekp-auto-justify-mode,以及 - 编辑器集成(保存、isearch、kill-ring、undo) +ekp-buffer.el 纯文本属性 buffer/region 投影、同步实时流动、窗口 + lifecycle、复制过滤与诊断 ekp_c/ C 动态模块(见 ekp_c/README.md) dictionaries/ Hunspell 断词模式(来自 LibreOffice) -tests/ ekp-tests.el、ekp-region-tests.el(ERT)、 +tests/ ekp-tests.el、ekp-buffer-tests.el(ERT)、 ekp-fuzz.el(一致性 fuzz)、ekp-bench.el、 ekp-demo.el、ekp-showcase.el、示例文本、run-tests.sh ``` diff --git a/Docs/REPOSITORY_AUDIT_20260728.md b/Docs/REPOSITORY_AUDIT_20260728.md index 0243970..a87f474 100644 --- a/Docs/REPOSITORY_AUDIT_20260728.md +++ b/Docs/REPOSITORY_AUDIT_20260728.md @@ -44,6 +44,9 @@ - `task015` 已完成终局清理与门禁:删除九个死 accessor 和依赖私有 substring-filter API 的调用;最终独立代码审查 `APPROVE`、架构审查 `CLEAR`,全部仓库内 issue/task 均已闭环。 +- `task017` 已将当前编辑器集成模块直接改名为 `ekp-buffer.el`; + 下文的 `ekp-region.el` 与 `ekp-region-*` 仍保留为审计基线的历史 + 证据,不代表当前加载入口。 - 下文保留 `main@29cef97` 的原始证据与判断,不把历史问题改写成从未发生。 ## 结论先行 diff --git a/ekp-buffer.el b/ekp-buffer.el new file mode 100644 index 0000000..f5ed5e4 --- /dev/null +++ b/ekp-buffer.el @@ -0,0 +1,2011 @@ +;;; ekp-buffer.el --- Non-mutating buffer layout for ekp -*- lexical-binding: t; -*- + +;; Copyright (C) 2024-2026 Kinney Zhang + +;; Author: Kinney Zhang +;; Keywords: wp, convenience + +;; This file is NOT part of GNU Emacs. + +;; 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 . + +;;; Commentary: + +;; `ekp.el' computes semantic KP layout plans. This module projects those +;; plans onto existing source characters with text properties only: +;; +;; - `space-width' plus `min-width' renders exact ASCII-space glue; +;; - `min-width' pads zero-source CJK and mixed gaps; +;; - `line-prefix' renders indentation and leading alignment; +;; - replacing display strings render chosen breaks and discretionary +;; hyphens without inserting characters. +;; +;; The logical character stream never changes. EKP creates no overlays. + +;;; Code: + +(require 'cl-lib) +(require 'easymenu) +(require 'ekp) + +(defvar ekp-auto-justify-mode) + +(defgroup ekp-buffer nil + "Non-mutating buffer layout built on ekp." + :group 'ekp + :prefix "ekp-") + +(defcustom ekp-buffer-margin-pixel 2 + "Pixels reserved inside the window body width." + :type 'natnum) + +(defcustom ekp-auto-justify-resize-delay 0.15 + "Seconds to debounce window resize reflows." + :type 'number) + +(defcustom ekp-auto-justify-composition-delay 0.05 + "Seconds before retrying a live layout deferred by IME composition." + :type 'number) + +(defcustom ekp-auto-justify-paragraph-limit 2048 + "Maximum hard-paragraph characters planned automatically. +Longer paragraphs stay natural so enabling the mode, pasting, and +ordinary editing cannot enter an unbounded paragraph-wide planning +operation. `ekp-refill-paragraph' remains the explicit full-quality +command and is not limited by this value." + :type 'natnum) + +(defcustom ekp-auto-justify-lazy-threshold 20000 + "Buffer size beyond which whole-buffer reflows run visible-first." + :type 'natnum) + +(defcustom ekp-auto-justify-chunk-size 10 + "Hard paragraphs processed in one lazy reflow chunk." + :type 'natnum) + +(defcustom ekp-auto-justify-tick-budget 0.005 + "Seconds of work allowed in one lazy reflow tick." + :type 'number) + +(defconst ekp-buffer-org-skip-faces + '(org-block org-block-begin-line org-block-end-line org-code + org-verbatim org-table org-meta-line) + "Reasonable `ekp-buffer-skip-faces' preset for Org buffers.") + +(defconst ekp-buffer-markdown-skip-faces + '(markdown-code-face markdown-inline-code-face markdown-pre-face + markdown-table-face) + "Reasonable `ekp-buffer-skip-faces' preset for Markdown buffers.") + +(defcustom ekp-buffer-skip-faces nil + "Faces whose paragraphs stay verbatim." + :type '(repeat face)) + +(defvar-local ekp-buffer-skip-predicate nil + "Function called with a paragraph string that should stay verbatim.") + +(defvar ekp-buffer--inhibit nil + "Non-nil while EKP changes projection properties.") + +(defvar ekp-buffer--filtering nil + "Non-nil while EKP delegates substring extraction to a prior filter.") + +(defvar ekp-buffer--automatic-pass nil + "Non-nil while an automatic operation lays out stable paragraphs.") + +(defvar-local ekp-buffer--previous-filter nil) +(defvar-local ekp-buffer--previous-filter-local-p nil) +(defvar-local ekp-buffer--filter-installed nil) +(defvar-local ekp-buffer--auto-width nil) +(defvar-local ekp-buffer--resize-timer nil) +(defvar-local ekp-buffer--composition-timer nil) +(defvar-local ekp-buffer--pending nil) +(defvar-local ekp-buffer--chunk-timer nil) +(defvar-local ekp-buffer--generation 0) +(defvar-local ekp-buffer--live-edit nil) +(defvar-local ekp-buffer--live-state nil) +(defvar-local ekp-buffer--live-plan-cache nil) +(defvar-local ekp-buffer--wrap-state nil + "Prior soft-wrap variable values and local-binding ownership.") +(defvar-local ekp-buffer--spans nil + "Installed projections as `ekp-buffer--span' records.") +(defvar-local ekp-buffer--conflicts nil + "Most recent skipped paragraph conflicts as (BEG END REASON).") + +(cl-defstruct (ekp-buffer--span (:constructor ekp-buffer--span-create)) + beg end width plan lines) + +(cl-defstruct + (ekp-buffer--projected-line + (:constructor ekp-buffer--projected-line-create)) + base beg end break-end line) + +(cl-defstruct + (ekp-buffer--live-edit (:constructor ekp-buffer--live-edit-create)) + old-beg old-fragment + baseline-source baseline-plan baseline-signatures baseline-spans + baseline-key baseline-active-index baseline-prefix-end baseline-markers + dirty-beg dirty-end dirty-start dirty-finish row-start edit-end) + +(cl-defstruct + (ekp-buffer--live-state (:constructor ekp-buffer--live-state-create)) + beg end width source key plan signatures active-index prefix-end spans) + +(defconst ekp-buffer--owned-properties + '(ekp-justified ekp-buffer--display ekp-buffer--line-prefix) + "Text properties that identify EKP's buffer projection.") + +(dolist (property ekp-buffer--owned-properties) + (setf (alist-get property text-property-default-nonsticky) t)) + +;;; Setup + +;;;###autoload +(defun ekp-org-setup () + "Protect common Org structural faces in the current buffer." + (setq-local ekp-buffer-skip-faces ekp-buffer-org-skip-faces)) + +;;;###autoload +(defun ekp-markdown-setup () + "Protect common Markdown code faces in the current buffer." + (setq-local ekp-buffer-skip-faces ekp-buffer-markdown-skip-faces)) + +;;; Width + +(defun ekp-buffer--protrusion-reserve () + "Return the right-edge pixel reserve for hanging punctuation." + (if ekp-protrusion + (max 2 (ceiling (* (alist-get 'cjk-close ekp-protrusion-ratios 0.5) + (ekp--measured-width "。")))) + 0)) + +(defun ekp-buffer--indicator-reserve (&optional window) + "Return pixels consumed by the continuation indicator in WINDOW." + (let ((win (or window (selected-window)))) + (if (and (display-graphic-p (window-frame win)) + (> (or (cadr (window-fringes win)) 0) 0)) + 0 + (frame-char-width (window-frame win))))) + +(defun ekp-buffer--window-pixel (&optional window) + "Return usable text width in pixels for WINDOW." + (max 1 (- (window-body-width window t) + ekp-buffer-margin-pixel + (ekp-buffer--indicator-reserve window) + (ekp-buffer--protrusion-reserve)))) + +(defun ekp-buffer--authoritative-window () + "Return the narrowest live window displaying the current buffer." + (car + (sort (seq-filter + #'window-live-p + (get-buffer-window-list (current-buffer) nil t)) + (lambda (left right) + (< (ekp-buffer--window-pixel left) + (ekp-buffer--window-pixel right)))))) + +(defun ekp-buffer--native-row-start (position) + "Return native visual-row start containing POSITION, or nil in batch." + (when-let* ((window (ekp-buffer--authoritative-window))) + (save-excursion + (goto-char position) + (vertical-motion 0 window) + (point)))) + +(defun ekp-buffer--effective-width (&optional buffer) + "Return the authoritative width for BUFFER. +The narrowest live window wins because text properties are buffer-wide." + (let ((windows (get-buffer-window-list + (or buffer (current-buffer)) nil t))) + (if windows + (apply #'min (mapcar #'ekp-buffer--window-pixel windows)) + (ekp-buffer--window-pixel)))) + +;;; Paragraphs and conflicts + +(defun ekp-buffer--face-hit-p (string) + "Return non-nil when STRING carries a configured skip face." + (let ((pos 0) (length (length string)) hit) + (while (and (< pos length) (not hit)) + (let ((face (get-text-property pos 'face string))) + (setq hit (if (listp face) + (seq-intersection face ekp-buffer-skip-faces) + (memq face ekp-buffer-skip-faces))) + (setq pos (or (next-single-property-change + pos 'face string length) + length)))) + hit)) + +(defun ekp-buffer--skip-paragraph-p (paragraph) + "Return non-nil when PARAGRAPH must stay verbatim." + (or (string-blank-p paragraph) + (text-property-not-all 0 (length paragraph) + 'ekp-verbatim nil paragraph) + (text-property-not-all 0 (length paragraph) 'field nil paragraph) + (text-property-not-all 0 (length paragraph) 'read-only nil paragraph) + (and ekp-buffer-skip-faces + (ekp-buffer--face-hit-p paragraph)) + (and ekp-buffer-skip-predicate + (funcall ekp-buffer-skip-predicate paragraph)))) + +(defun ekp-buffer--foreign-property-at-p (position property) + "Return non-nil when PROPERTY at POSITION is not owned by EKP." + (let ((value (get-text-property position property))) + (and value + (pcase property + ('display + (not (eq value + (get-text-property + position 'ekp-buffer--display)))) + ('line-prefix + (not (eq value + (get-text-property + position 'ekp-buffer--line-prefix)))) + (_ t))))) + +(defun ekp-buffer--foreign-property (beg end) + "Return the first uncomposable foreign property in BEG through END." + (seq-find + (lambda (property) + (let ((position beg) + found) + (while (and (< position end) (not found)) + (setq found + (ekp-buffer--foreign-property-at-p position property) + position + (or (next-single-property-change + position property nil end) + end))) + found)) + '(display line-prefix wrap-prefix composition invisible))) + +(defun ekp-buffer--ascii-space-range-p (beg end) + "Return non-nil when BEG through END contain only ASCII spaces." + (and (< beg end) + (let ((position beg)) + (while (and (< position end) + (= (char-after position) ?\s)) + (setq position (1+ position))) + (= position end)))) + +(defun ekp-buffer--unsupported-gap-p (gap base) + "Return non-nil when GAP cannot be projected at BASE." + (let ((start (+ base (ekp-layout-gap-source-start gap))) + (end (+ base (ekp-layout-gap-source-end gap)))) + (and (< start end) + (not (ekp-buffer--ascii-space-range-p start end)) + (< (ekp-layout-gap-target-pixel gap) + (ekp-layout-gap-natural-pixel gap))))) + +(defun ekp-buffer--unsupported-gap (plan base) + "Return the first gap in PLAN that cannot be projected at BASE." + (catch 'unsupported + (cl-loop + for line across (ekp-layout-plan-lines plan) + do + (cl-loop + for gap across (ekp-layout-line-gaps line) + when (ekp-buffer--unsupported-gap-p gap base) + do (throw 'unsupported gap))))) + +(defun ekp-buffer--record-conflict (beg end reason) + "Record that BEG through END stayed verbatim because of REASON." + (push (list beg end reason) ekp-buffer--conflicts)) + +(defun ekp-buffer--projectable-p (plan base beg end) + "Return non-nil when PLAN can be installed at BASE from BEG to END." + (if-let* ((property (ekp-buffer--foreign-property beg end))) + (progn + (ekp-buffer--record-conflict beg end + (format "foreign `%s' property" property)) + nil) + (if (ekp-buffer--unsupported-gap plan base) + (progn + (ekp-buffer--record-conflict + beg end "non-ASCII whitespace would require shrinking") + nil) + t))) + +;;; Owned property operations + +(defun ekp-buffer--put-owned + (beg end property owner value) + "Put PROPERTY VALUE on BEG through END and record it in OWNER." + (when (< beg end) + (add-text-properties beg end (list property value owner value)))) + +(defun ekp-buffer--remove-owned (beg end property owner) + "Remove EKP-owned PROPERTY spans identified by OWNER from BEG to END." + (let ((pos beg)) + (while (< pos end) + (let* ((value (get-text-property pos owner)) + (next (or (next-single-property-change pos owner nil end) end))) + (when value + (remove-text-properties + pos next + (if (eq (get-text-property pos property) value) + (list property nil owner nil) + (list owner nil)))) + (setq pos next))))) + +(defun ekp-buffer--put-display (beg end value) + "Install EKP-owned display VALUE from BEG to END." + (ekp-buffer--put-owned beg end 'display 'ekp-buffer--display value)) + +(defun ekp-buffer--put-line-prefix (beg end value) + "Install EKP-owned line-prefix VALUE from BEG to END." + (ekp-buffer--put-owned + beg end 'line-prefix 'ekp-buffer--line-prefix value)) + +(defun ekp-buffer--remove-properties (beg end) + "Remove only EKP-owned projection properties from BEG to END." + (ekp-buffer--remove-owned beg end 'display 'ekp-buffer--display) + (ekp-buffer--remove-owned + beg end 'line-prefix 'ekp-buffer--line-prefix) + (remove-text-properties beg end '(ekp-justified nil))) + +;;; Display specifications + +(defun ekp-buffer--min-width (pixel) + "Return an independently identified absolute PIXEL `min-width' spec." + (list 'min-width (list (list pixel)))) + +(defun ekp-buffer--space-display (natural target) + "Return exact ASCII-space display for NATURAL and TARGET pixels." + (if (= target 0) + (make-string 0 0) + (list (list 'space-width (/ (float target) (max 1 natural))) + (ekp-buffer--min-width target)))) + +(defun ekp-buffer--prefix-display (pixel) + "Return a display-only line prefix of PIXEL width." + (propertize " " 'display `(space :width (,pixel)))) + +(defun ekp-buffer--tail-grapheme-range (plan box-index base) + "Return buffer range of BOX-INDEX's final grapheme in PLAN at BASE." + (let* ((offset (aref (ekp-layout-plan-offsets plan) box-index)) + (box (aref (ekp-layout-plan-boxes plan) box-index)) + (glyphs (string-glyph-split box)) + (length (length (car (last glyphs)))) + (end (+ base (cdr offset)))) + (cons (- end length) end))) + +(defun ekp-buffer--clean-display-copy (beg end) + "Return BEG through END for use inside a replacing display string." + (let ((copy (buffer-substring beg end))) + (remove-text-properties + 0 (length copy) + '(display nil line-prefix nil wrap-prefix nil + ekp-justified nil ekp-buffer--display nil + ekp-buffer--line-prefix nil) + copy) + copy)) + +(defun ekp-buffer--break-display (beg end hyphen-p) + "Return a display string for source grapheme BEG through END. +HYPHEN-P adds a discretionary hyphen before the visual newline." + (let* ((grapheme (ekp-buffer--clean-display-copy beg end)) + (props (and (> (length grapheme) 0) + (text-properties-at (1- (length grapheme)) grapheme))) + (hyphen (if hyphen-p (apply #'propertize "-" props) ""))) + (when (> (length grapheme) 0) + (put-text-property 0 (length grapheme) 'cursor 1 grapheme)) + (concat grapheme hyphen "\n"))) + +;;; Plan projection + +(defun ekp-buffer--project-source-gap (gap base) + "Project a source-backed GAP at BASE." + (let* ((beg (+ base (ekp-layout-gap-source-start gap))) + (end (+ base (ekp-layout-gap-source-end gap))) + (target (ekp-layout-gap-target-pixel gap)) + (natural (ekp-layout-gap-natural-pixel gap))) + (cond + ((ekp-buffer--ascii-space-range-p beg end) + (ekp-buffer--put-display + beg end (ekp-buffer--space-display natural target))) + ((= target 0) + (ekp-buffer--put-display beg end (make-string 0 0))) + ((> target natural) + (ekp-buffer--put-display + beg end (ekp-buffer--min-width target)))))) + +(defun ekp-buffer--project-zero-source-gap (gap plan base) + "Project a zero-source GAP from PLAN at BASE." + (let ((target (ekp-layout-gap-target-pixel gap))) + (when (> target 0) + (pcase-let ((`(,beg . ,end) + (ekp-buffer--tail-grapheme-range + plan (ekp-layout-gap-left-box gap) base))) + (ekp-buffer--put-display + beg end + (ekp-buffer--min-width + (+ (ekp-layout-gap-natural-pixel gap) target))))))) + +(defun ekp-buffer--project-gap (gap plan base) + "Project GAP from PLAN at buffer BASE." + (if (< (ekp-layout-gap-source-start gap) + (ekp-layout-gap-source-end gap)) + (ekp-buffer--project-source-gap gap base) + (ekp-buffer--project-zero-source-gap gap plan base))) + +(defun ekp-buffer--project-break-space (beg end) + "Project a chosen visual break over source characters BEG through END." + (ekp-buffer--put-display beg (1+ beg) (copy-sequence "\n")) + (when (< (1+ beg) end) + (ekp-buffer--put-display (1+ beg) end (make-string 0 0)))) + +(defun ekp-buffer--project-break (line plan base) + "Project LINE's chosen break from PLAN at BASE." + (when (ekp-layout-line-break-kind line) + (let ((beg (+ base (ekp-layout-line-break-source-start line))) + (end (+ base (ekp-layout-line-break-source-end line)))) + (if (< beg end) + (ekp-buffer--project-break-space beg end) + (pcase-let ((`(,owner-beg . ,owner-end) + (ekp-buffer--tail-grapheme-range + plan (1- (ekp-layout-line-box-end line)) base))) + (ekp-buffer--put-display + owner-beg owner-end + (ekp-buffer--break-display + owner-beg owner-end (ekp-layout-line-hyphen-p line)))))))) + +(defun ekp-buffer--project-line (line plan base &optional natural-p) + "Project one LINE from PLAN at BASE. +NATURAL-P leaves source gaps at their ordinary display widths." + (let ((beg (+ base (ekp-layout-line-source-start line))) + (end (+ base (ekp-layout-line-source-end line))) + (indent (ekp-layout-line-leading-pixel line))) + (when (> indent 0) + (ekp-buffer--put-line-prefix + beg end (ekp-buffer--prefix-display indent))) + (unless natural-p + (cl-loop for gap across (ekp-layout-line-gaps line) + do (ekp-buffer--project-gap gap plan base))) + (ekp-buffer--project-break line plan base))) + +(defun ekp-buffer--project-edges (plan base) + "Hide stripped paragraph edges from PLAN at BASE." + (let ((lines (ekp-layout-plan-lines plan)) + (length (length (ekp-layout-plan-string plan)))) + (when (> (length lines) 0) + (let ((first (aref lines 0)) + (last (aref lines (1- (length lines))))) + (when (> (ekp-layout-line-source-start first) 0) + (ekp-buffer--put-display + base (+ base (ekp-layout-line-source-start first)) + (make-string 0 0))) + (when (< (ekp-layout-line-source-end last) length) + (ekp-buffer--put-display + (+ base (ekp-layout-line-source-end last)) (+ base length) + (make-string 0 0))))))) + +(defun ekp-buffer--projected-line-from-plan + (line base &optional effective-end) + "Return a record for LINE projected from BASE. +EFFECTIVE-END includes the source boundary owned by the line." + (ekp-buffer--projected-line-create + :base (copy-marker base) + :beg (copy-marker (+ base (ekp-layout-line-source-start line))) + :end (copy-marker (+ base (ekp-layout-line-source-end line)) t) + :break-end + (copy-marker + (or effective-end + (+ base (ekp-layout-line-break-source-end line))) + t) + :line line)) + +(defun ekp-buffer--plan-projected-lines (plan base) + "Return projected line records for PLAN at BASE." + (let* ((lines (ekp-layout-plan-lines plan)) + (last (1- (length lines)))) + (cl-loop + for line across lines + for index from 0 + collect + (ekp-buffer--projected-line-from-plan + line base + (and (= index last) + (+ base (length (ekp-layout-plan-string plan)))))))) + +(defun ekp-buffer--register-span (beg end width plan) + "Register a projection from BEG to END at WIDTH using PLAN." + (push (ekp-buffer--span-create + :beg (copy-marker beg) + :end (copy-marker end t) + :width width + :plan plan + :lines (ekp-buffer--plan-projected-lines plan beg)) + ekp-buffer--spans)) + +(defun ekp-buffer--install-plan (beg end width plan) + "Install PLAN from BEG to END at WIDTH." + (let ((base beg)) + (with-silent-modifications + (ekp-buffer--project-edges plan base) + (cl-loop for line across (ekp-layout-plan-lines plan) + do (ekp-buffer--project-line line plan base)) + (put-text-property beg end 'ekp-justified width)) + (ekp-buffer--register-span beg end width plan))) + +;;; Projection lifecycle + +(defun ekp-buffer--span-overlaps-p (span beg end) + "Return non-nil when SPAN intersects BEG through END." + (let ((start (marker-position (ekp-buffer--span-beg span))) + (finish (marker-position (ekp-buffer--span-end span)))) + (and start finish + (if (= beg end) + (<= start beg finish) + (and (< start end) (> finish beg)))))) + +(defun ekp-buffer--discard-span (span) + "Remove SPAN's markers." + (dolist (line (ekp-buffer--span-lines span)) + (dolist (marker + (list (ekp-buffer--projected-line-base line) + (ekp-buffer--projected-line-beg line) + (ekp-buffer--projected-line-end line) + (ekp-buffer--projected-line-break-end line))) + (set-marker marker nil))) + (set-marker (ekp-buffer--span-beg span) nil) + (set-marker (ekp-buffer--span-end span) nil)) + +(defun ekp-buffer--clear-projection (beg end) + "Clear complete projected spans intersecting BEG through END." + (let ((targets (cl-remove-if-not + (lambda (span) + (ekp-buffer--span-overlaps-p span beg end)) + ekp-buffer--spans))) + (when targets + (let ((ekp-buffer--inhibit t)) + (with-silent-modifications + (dolist (span targets) + (let ((start (marker-position (ekp-buffer--span-beg span))) + (finish (marker-position (ekp-buffer--span-end span)))) + (when (and start finish) + (ekp-buffer--remove-properties start finish))) + (setq ekp-buffer--spans (delq span ekp-buffer--spans)) + (ekp-buffer--discard-span span))))))) + +(defun ekp-buffer--clear-all () + "Remove every EKP projection from the widened buffer." + (save-restriction + (widen) + (ekp-buffer--clear-projection (point-min) (point-max)))) + +;;; Commands + +(defun ekp-buffer--paragraph-ranges (beg end) + "Return hard paragraph ranges inside BEG through END." + (let (ranges) + (save-excursion + (goto-char beg) + (while (< (point) end) + (let ((start (point)) + (finish (if (search-forward "\n" end t) + (1- (point)) + end))) + (push (cons start finish) ranges) + (if (< finish end) + (goto-char (1+ finish)) + (goto-char end))))) + (nreverse ranges))) + +(defun ekp-buffer--layout-paragraph (beg end pixel) + "Project one hard paragraph from BEG to END at PIXEL." + (if (and ekp-buffer--automatic-pass + (> (- end beg) ekp-auto-justify-paragraph-limit)) + (ekp-buffer--record-conflict + beg end "paragraph exceeds the automatic paragraph limit") + (let ((paragraph (buffer-substring beg end))) + (unless (ekp-buffer--skip-paragraph-p paragraph) + (let ((plan (ekp-layout-plan paragraph pixel))) + (when (ekp-buffer--projectable-p plan beg beg end) + (ekp-buffer--install-plan beg end pixel plan))))))) + +(defun ekp-buffer--dwim-bounds () + "Return active region bounds or the hard paragraph at point." + (if (use-region-p) + (cons (region-beginning) (region-end)) + (ekp-buffer--para-bounds (cons (point) (point))))) + +;;;###autoload +(defun ekp-justify-region (beg end &optional pixel) + "Project BEG through END as KP layout at PIXEL without changing text." + (interactive + (progn + (barf-if-buffer-read-only) + (pcase-let ((`(,start . ,finish) (ekp-buffer--dwim-bounds))) + (list start finish + (and current-prefix-arg + (prefix-numeric-value current-prefix-arg)))))) + (setq pixel (or pixel (ekp-buffer--window-pixel))) + (let ((start (min beg end)) + (finish (max beg end))) + (setq beg start + end finish)) + (when (and font-lock-mode + (or ekp-buffer-skip-faces ekp-buffer-skip-predicate)) + (font-lock-ensure beg end)) + (let ((point-before (point)) + (mark-before (and (mark t) (copy-marker (mark t)))) + (mark-active-before mark-active) + (ekp-buffer--inhibit t)) + (unwind-protect + (progn + (ekp-buffer--clear-projection beg end) + (setq ekp-buffer--conflicts nil) + (dolist (range (ekp-buffer--paragraph-ranges beg end)) + (ekp-buffer--layout-paragraph (car range) (cdr range) pixel)) + (ekp-buffer--install-integrations) + (goto-char point-before) + (when mark-before + (set-marker (mark-marker) (marker-position mark-before))) + (setq mark-active mark-active-before)) + (when mark-before (set-marker mark-before nil))))) + +;;;###autoload +(defun ekp-unjustify-region (beg end) + "Remove EKP display projection intersecting BEG through END." + (interactive + (progn + (barf-if-buffer-read-only) + (pcase-let ((`(,start . ,finish) (ekp-buffer--dwim-bounds))) + (list start finish)))) + (ekp-buffer--clear-projection (min beg end) (max beg end)) + (unless (or ekp-auto-justify-mode ekp-buffer--spans) + (ekp-buffer--remove-integrations))) + +;;;###autoload +(defun ekp-justify-buffer (&optional pixel) + "Project the accessible buffer as KP layout at PIXEL." + (interactive + (progn + (barf-if-buffer-read-only) + (list (and current-prefix-arg + (prefix-numeric-value current-prefix-arg))))) + (ekp-justify-region (point-min) (point-max) pixel)) + +;;;###autoload +(defun ekp-unjustify-buffer () + "Remove EKP projection from the accessible buffer." + (interactive "*") + (ekp-unjustify-region (point-min) (point-max))) + +;;; Copy integration + +(defun ekp-buffer--strip-owned-from-string (string) + "Remove EKP projection properties from copied STRING." + (let ((pos 0) (length (length string))) + (while (< pos length) + (let ((next (or (next-property-change pos string length) length))) + (set-text-properties + pos next + (ekp-buffer--clean-property-list + (text-properties-at pos string)) + string) + (setq pos next))) + string)) + +(defun ekp-buffer--clean-property-list (properties) + "Return PROPERTIES with EKP-owned projection properties removed." + (let ((display (plist-get properties 'ekp-buffer--display)) + (prefix (plist-get properties 'ekp-buffer--line-prefix)) + clean) + (while properties + (let ((property (pop properties)) + (value (pop properties))) + (unless (or (memq property ekp-buffer--owned-properties) + (and (eq property 'display) (eq value display)) + (and (eq property 'line-prefix) (eq value prefix))) + (setq clean (plist-put clean property value))))) + clean)) + +(defun ekp-buffer--filter-buffer-substring (beg end &optional delete) + "Extract logical BEG through END, optionally DELETE, without projection." + (let (extracted) + (let ((filter-buffer-substring-function ekp-buffer--previous-filter) + (ekp-buffer--filtering t)) + (setq extracted (filter-buffer-substring beg end delete))) + (when delete + (ekp-buffer--after-layout-change)) + (ekp-buffer--strip-owned-from-string extracted))) + +(defun ekp-buffer--install-integrations () + "Install integrations needed by manual spans or auto mode." + (add-hook 'before-change-functions #'ekp-buffer--before-change nil t) + (add-hook 'after-change-functions + #'ekp-buffer--after-layout-change nil t) + (unless ekp-buffer--filter-installed + (setq ekp-buffer--previous-filter-local-p + (local-variable-p 'filter-buffer-substring-function) + ekp-buffer--previous-filter filter-buffer-substring-function + ekp-buffer--filter-installed t) + (setq-local filter-buffer-substring-function + #'ekp-buffer--filter-buffer-substring))) + +(defun ekp-buffer--remove-integrations () + "Remove EKP integrations and restore the prior substring filter." + (remove-hook 'before-change-functions #'ekp-buffer--before-change t) + (remove-hook 'after-change-functions + #'ekp-buffer--after-layout-change t) + (when ekp-buffer--filter-installed + (when (eq filter-buffer-substring-function + #'ekp-buffer--filter-buffer-substring) + (if ekp-buffer--previous-filter-local-p + (setq-local filter-buffer-substring-function + ekp-buffer--previous-filter) + (kill-local-variable 'filter-buffer-substring-function))) + (setq ekp-buffer--previous-filter nil + ekp-buffer--previous-filter-local-p nil + ekp-buffer--filter-installed nil))) + +;;; Protection commands + +(defun ekp-buffer--interactive-protection-args () + "Return region arguments for an interactive protection command." + (barf-if-buffer-read-only) + (list (region-beginning) (region-end) t)) + +(defun ekp-buffer--set-protection (beg end property enabled label announce) + "Set PROPERTY to ENABLED from BEG to END and optionally ANNOUNCE LABEL." + (if enabled + (add-text-properties beg end (list property t)) + (remove-text-properties beg end (list property nil))) + (when announce + (message "EKP: %s on %d characters; current buffer session only" + label (- end beg)))) + +;;;###autoload +(defun ekp-no-break-region (beg end &optional announce) + "Mark BEG through END as an unbreakable session-local atom. +ANNOUNCE requests interactive feedback." + (interactive (ekp-buffer--interactive-protection-args)) + (ekp-buffer--set-protection + beg end 'ekp-no-break t "Marked no-break" announce)) + +;;;###autoload +(defun ekp-allow-break-region (beg end &optional announce) + "Clear session-local `ekp-no-break' from BEG through END. +ANNOUNCE requests interactive feedback." + (interactive (ekp-buffer--interactive-protection-args)) + (ekp-buffer--set-protection + beg end 'ekp-no-break nil "Cleared no-break" announce)) + +;;;###autoload +(defun ekp-verbatim-region (beg end &optional announce) + "Keep paragraphs intersecting BEG through END verbatim this session. +ANNOUNCE requests interactive feedback." + (interactive (ekp-buffer--interactive-protection-args)) + (ekp-buffer--set-protection + beg end 'ekp-verbatim t "Marked verbatim" announce)) + +;;;###autoload +(defun ekp-clear-verbatim-region (beg end &optional announce) + "Clear session-local `ekp-verbatim' from BEG through END. +ANNOUNCE requests interactive feedback." + (interactive (ekp-buffer--interactive-protection-args)) + (ekp-buffer--set-protection + beg end 'ekp-verbatim nil "Cleared verbatim" announce)) + +;;; Auto mode + +(defun ekp-buffer--para-bounds (marker-pair) + "Return hard paragraph bounds containing MARKER-PAIR." + (let ((beg (if (markerp (car marker-pair)) + (marker-position (car marker-pair)) + (car marker-pair))) + (end (if (markerp (cdr marker-pair)) + (marker-position (cdr marker-pair)) + (cdr marker-pair)))) + (save-excursion + (goto-char (max (point-min) (min beg (point-max)))) + (setq beg (line-beginning-position)) + (goto-char (max (point-min) (min end (point-max)))) + (setq end (line-end-position)) + (cons beg end)))) + +(defun ekp-buffer--span-at (beg end) + "Return the projection span intersecting BEG through END." + (seq-find + (lambda (span) (ekp-buffer--span-overlaps-p span beg end)) + ekp-buffer--spans)) + +(defun ekp-buffer--release-live-edit () + "Detach markers owned by the current live edit transaction." + (when ekp-buffer--live-edit + (dolist (marker + (delq nil + (list + (ekp-buffer--live-edit-old-beg ekp-buffer--live-edit) + (ekp-buffer--live-edit-dirty-beg ekp-buffer--live-edit) + (ekp-buffer--live-edit-dirty-end ekp-buffer--live-edit) + (ekp-buffer--live-edit-row-start ekp-buffer--live-edit) + (ekp-buffer--live-edit-edit-end ekp-buffer--live-edit)))) + (set-marker marker nil)) + (setq ekp-buffer--live-edit nil))) + +(defun ekp-buffer--detach-live-markers () + "Detach every marker owned by the current live state." + (dolist (marker + (list (ekp-buffer--live-state-beg ekp-buffer--live-state) + (ekp-buffer--live-state-end ekp-buffer--live-state) + (ekp-buffer--live-state-prefix-end + ekp-buffer--live-state))) + (set-marker marker nil))) + +(defun ekp-buffer--release-live-state () + "Detach markers and projection owned by the active live hard line." + (when ekp-buffer--live-state + (let ((beg (marker-position + (ekp-buffer--live-state-beg ekp-buffer--live-state))) + (end (marker-position + (ekp-buffer--live-state-end ekp-buffer--live-state)))) + (when (and beg end) + (ekp-buffer--clear-live-projection beg end))) + (ekp-buffer--detach-live-markers) + (setq ekp-buffer--live-state nil))) + +(defun ekp-buffer--detach-live-state () + "Detach live state markers while preserving installed spans." + (when ekp-buffer--live-state + (ekp-buffer--detach-live-markers) + (setq ekp-buffer--live-state nil))) + +(defun ekp-buffer--live-state-contains-p (position) + "Return non-nil if source POSITION is inside the live state." + (when ekp-buffer--live-state + (let ((beg (marker-position + (ekp-buffer--live-state-beg ekp-buffer--live-state))) + (end (marker-position + (ekp-buffer--live-state-end ekp-buffer--live-state)))) + (and beg end (<= beg position end))))) + +(defun ekp-buffer--activate-live-paragraph (beg end) + "Make BEG through END the active hard line." + (ekp-buffer--release-live-state) + (ekp-buffer--clear-projection beg end) + (setq ekp-buffer--live-state + (ekp-buffer--live-state-create + :beg (copy-marker beg) + :end (copy-marker end t) + :source (ekp-buffer--logical-substring beg end) + :prefix-end (copy-marker beg)))) + +(defun ekp-buffer--commit-live-paragraph () + "Commit the active hard line as one complete global plan." + (when ekp-buffer--live-state + (let ((end (marker-position + (ekp-buffer--live-state-end ekp-buffer--live-state)))) + (when end + (ekp-buffer--publish-live-prefix end t))) + (ekp-buffer--release-live-edit))) + +(defun ekp-buffer--finalize-live-paragraph () + "Commit and detach the active hard line while preserving projection." + (when ekp-buffer--live-state + (ekp-buffer--commit-live-paragraph) + (ekp-buffer--detach-live-state))) + +(defun ekp-buffer--prepare-live-paragraph (beg end) + "Ensure BEG through END is active with a stable committed baseline." + (unless (and (ekp-buffer--live-state-contains-p beg) + (ekp-buffer--live-state-contains-p end)) + (when ekp-buffer--live-state + (save-restriction + (widen) + (ekp-buffer--finalize-live-paragraph))) + (ekp-buffer--activate-live-paragraph beg end) + (ekp-buffer--publish-live-prefix end t))) + +(defun ekp-buffer--live-edit-contains-p (beg end) + "Return non-nil when BEG through END stay inside the dirty island." + (when ekp-buffer--live-edit + (let ((start (marker-position + (ekp-buffer--live-edit-dirty-beg + ekp-buffer--live-edit))) + (finish (marker-position + (ekp-buffer--live-edit-dirty-end + ekp-buffer--live-edit)))) + (and start finish (<= start beg end finish))))) + +(defun ekp-buffer--live-span-edit-hit-p (span beg end line-end) + "Return non-nil when SPAN owns the edit from BEG through END. +LINE-END lets insertion at the hard-line end belong to its last span." + (let ((start (marker-position (ekp-buffer--span-beg span))) + (finish (marker-position (ekp-buffer--span-end span)))) + (and start finish + (if (= beg end) + (and (<= start beg) + (or (< beg finish) + (and (= beg line-end) (= finish line-end)))) + (and (< start end) (> finish beg)))))) + +(defun ekp-buffer--live-dirty-range (beg end) + "Return the smallest committed island owning BEG through END." + (let* ((state ekp-buffer--live-state) + (line-beg (marker-position (ekp-buffer--live-state-beg state))) + (line-end (marker-position (ekp-buffer--live-state-end state))) + (prefix-end + (marker-position (ekp-buffer--live-state-prefix-end state))) + (targets + (cl-remove-if-not + (lambda (span) + (ekp-buffer--live-span-edit-hit-p span beg end line-end)) + (ekp-buffer--live-state-spans state)))) + (if targets + (cons (marker-position (ekp-buffer--span-beg (car targets))) + (marker-position + (ekp-buffer--span-end (car (last targets))))) + (if (>= beg prefix-end) + (cons prefix-end line-end) + (cons line-beg prefix-end))))) + +(defun ekp-buffer--naturalize-live-range (beg end) + "Remove EKP-owned display properties from BEG through END." + (let ((ekp-buffer--inhibit t)) + (with-silent-modifications + (save-restriction + (widen) + (ekp-buffer--remove-properties beg end))))) + +(defun ekp-buffer--projected-line-markers (line) + "Return every marker owned by projected LINE." + (list (ekp-buffer--projected-line-base line) + (ekp-buffer--projected-line-beg line) + (ekp-buffer--projected-line-end line) + (ekp-buffer--projected-line-break-end line))) + +(defun ekp-buffer--live-state-owned-markers () + "Return all live-state and projection markers." + (append + (list (ekp-buffer--live-state-beg ekp-buffer--live-state) + (ekp-buffer--live-state-end ekp-buffer--live-state) + (ekp-buffer--live-state-prefix-end ekp-buffer--live-state)) + (cl-mapcan + (lambda (span) + (append + (list (ekp-buffer--span-beg span) + (ekp-buffer--span-end span)) + (cl-mapcan #'ekp-buffer--projected-line-markers + (ekp-buffer--span-lines span)))) + (ekp-buffer--live-state-spans ekp-buffer--live-state)))) + +(defun ekp-buffer--live-marker-snapshot (base) + "Return live marker offsets relative to BASE." + (mapcar + (lambda (marker) + (cons marker (- (marker-position marker) base))) + (ekp-buffer--live-state-owned-markers))) + +(defun ekp-buffer--start-live-edit (beg end bounds) + "Start a stable edit transaction for BEG through END in BOUNDS." + (let* ((state ekp-buffer--live-state) + (base (marker-position (ekp-buffer--live-state-beg state))) + (finish (marker-position (ekp-buffer--live-state-end state))) + (range (ekp-buffer--live-dirty-range beg end)) + (dirty-beg (car range)) + (dirty-end (cdr range)) + (baseline (buffer-substring base finish))) + (ekp-buffer--naturalize-live-range dirty-beg dirty-end) + (setq ekp-buffer--live-edit + (ekp-buffer--live-edit-create + :old-beg (copy-marker (car bounds)) + :baseline-source baseline + :baseline-plan (ekp-buffer--live-state-plan state) + :baseline-signatures (ekp-buffer--live-state-signatures state) + :baseline-spans (ekp-buffer--live-state-spans state) + :baseline-key (ekp-buffer--live-state-key state) + :baseline-active-index + (ekp-buffer--live-state-active-index state) + :baseline-prefix-end + (- (marker-position (ekp-buffer--live-state-prefix-end state)) + base) + :baseline-markers (ekp-buffer--live-marker-snapshot base) + :dirty-beg (copy-marker dirty-beg) + :dirty-end (copy-marker dirty-end t) + :dirty-start (- dirty-beg base) + :dirty-finish (- dirty-end base) + :row-start + (copy-marker + (or (ekp-buffer--native-row-start beg) dirty-beg)) + :edit-end (copy-marker end t))))) + +(defun ekp-buffer--prepare-live-edit (beg end bounds) + "Reuse or create the live transaction for BEG through END in BOUNDS." + (unless (ekp-buffer--live-edit-contains-p beg end) + (if (and ekp-buffer--live-edit + (ekp-buffer--live-state-contains-p beg) + (ekp-buffer--live-state-contains-p end)) + (ekp-buffer--commit-live-paragraph) + (when ekp-buffer--live-edit + (ekp-buffer--finalize-live-paragraph))) + (ekp-buffer--prepare-live-paragraph (car bounds) (cdr bounds)) + (ekp-buffer--start-live-edit beg end bounds)) + (setf (ekp-buffer--live-edit-old-fragment ekp-buffer--live-edit) + (buffer-substring-no-properties beg end))) + +(defun ekp-buffer--before-change (beg end) + "Prepare one stable dirty island for BEG through END." + (unless ekp-buffer--inhibit + (if ekp-auto-justify-mode + (let ((bounds (ekp-buffer--para-bounds (cons beg end)))) + (ekp-buffer--cancel-pending) + (ekp-buffer--prepare-live-edit beg end bounds) + (cl-incf ekp-buffer--generation) + (set-marker + (ekp-buffer--live-edit-edit-end ekp-buffer--live-edit) + end)) + (when (ekp-buffer--span-at beg end) + (ekp-buffer--clear-projection beg end))))) + +(defun ekp-buffer--after-layout-change (&rest _ignored) + "Release manual integrations after the final projection disappears." + (unless (or ekp-buffer--inhibit + ekp-buffer--filtering + ekp-auto-justify-mode + ekp-buffer--spans) + (ekp-buffer--remove-integrations))) + +(defun ekp-buffer--composing-p () + "Return non-nil while an input-method preedit is active." + (or (and (bound-and-true-p quail-overlay) + (overlayp quail-overlay) + (overlay-buffer quail-overlay)) + (get-text-property (point) 'composition) + (and (> (point) (point-min)) + (get-text-property (1- (point)) 'composition)))) + +(defun ekp-buffer--release-marker-pairs (pairs) + "Detach both markers in every element of PAIRS." + (dolist (pair pairs) + (set-marker (car pair) nil) + (set-marker (cdr pair) nil))) + +(defun ekp-buffer--logical-substring (beg end) + "Return BEG through END with EKP projection properties removed." + (ekp-buffer--strip-owned-from-string (buffer-substring beg end))) + +(defun ekp-buffer--live-context (width) + "Return non-text layout context for a live plan at WIDTH." + (list (ekp--dp-key width) + (copy-tree (ekp--width-context)) + (mapcar (lambda (attribute) + (face-attribute 'default attribute nil t)) + '(:family :height :width :weight :slant)) + ekp-latin-lang + ekp-alignment + ekp-ragged-stretch-pixel + (and ekp-protrusion (copy-tree ekp-protrusion-ratios)) + (copy-tree ekp-parshape) + ekp-first-line-indent + ekp-cjk-no-line-start-extra + (ekp--spacing-signature))) + +(defun ekp-buffer--live-key-equal-p (left right) + "Return non-nil when live cache keys LEFT and RIGHT are equivalent." + (and (equal (cadr left) (cadr right)) + (equal-including-properties (car left) (car right)))) + +(defun ekp-buffer--live-cache-get (key) + "Return cached plan for KEY and move it to the front." + (when-let* ((cell + (seq-find + (lambda (entry) + (ekp-buffer--live-key-equal-p key (car entry))) + ekp-buffer--live-plan-cache))) + (setq ekp-buffer--live-plan-cache + (cons cell (delq cell ekp-buffer--live-plan-cache))) + (cdr cell))) + +(defun ekp-buffer--live-cache-put (key plan) + "Store PLAN under KEY in the bounded live cache." + (setq ekp-buffer--live-plan-cache + (cons (cons key plan) + (cl-remove key ekp-buffer--live-plan-cache + :key #'car + :test #'ekp-buffer--live-key-equal-p))) + (when (> (length ekp-buffer--live-plan-cache) 16) + (setcdr (nthcdr 15 ekp-buffer--live-plan-cache) nil)) + plan) + +(defun ekp-buffer--live-plan-entry (text width) + "Return the cache entry for TEXT at WIDTH." + (let ((key (list text (ekp-buffer--live-context width)))) + (cons key + (or (ekp-buffer--live-cache-get key) + (ekp-buffer--live-cache-put + key (ekp-layout-plan text width)))))) + +(defun ekp-buffer--single-line-live-p (text width) + "Return non-nil if TEXT is conservatively known to fit WIDTH." + (and (= ekp-looseness 0) + (not ekp-parshape) + (not ekp-first-line-indent) + (not (string-match-p "[\t]" text)) + (<= (ekp--measured-width text) width))) + +(defun ekp-buffer--line-break-end (line) + "Return LINE's semantic ownership end." + (max (ekp-layout-line-source-end line) + (or (ekp-layout-line-break-source-end line) + (ekp-layout-line-source-end line)))) + +(defun ekp-buffer--active-line-index (plan offset) + "Return the semantic line index owning OFFSET in PLAN." + (let ((lines (ekp-layout-plan-lines plan)) + found) + (dotimes (index (length lines)) + (let ((line (aref lines index))) + (when (and (not found) + (<= (ekp-layout-line-source-start line) offset) + (or (< offset (ekp-buffer--line-break-end line)) + (and (= index (1- (length lines))) + (<= offset (ekp-buffer--line-break-end line))))) + (setq found index)))) + (or found (max 0 (1- (length lines)))))) + +(defun ekp-buffer--live-line-signature (line text) + "Return a buffer projection signature for LINE in TEXT." + (let* ((start (ekp-layout-line-source-start line)) + (end (ekp-buffer--line-break-end line)) + (owned (substring text start end))) + (list line owned (ekp--key-intervals owned)))) + +(defun ekp-buffer--live-prefix-signatures (plan active) + "Return signatures for semantic lines before ACTIVE in PLAN." + (let ((text (ekp-layout-plan-string plan)) + signatures) + (dotimes (index active) + (push (ekp-buffer--live-line-signature + (aref (ekp-layout-plan-lines plan) index) + text) + signatures)) + (nreverse signatures))) + +(defun ekp-buffer--make-live-line-span (base beg end width line) + "Return one live LINE span from BASE through BEG and END at WIDTH." + (let ((span (ekp-buffer--span-create + :beg (copy-marker beg) + :end (copy-marker end) + :width width + :plan nil + :lines (list (ekp-buffer--projected-line-from-plan + line base end))))) + (push span ekp-buffer--spans) + span)) + +(defun ekp-buffer--common-prefix-length (left right) + "Return the equal prefix length shared by LEFT and RIGHT." + (let ((count 0)) + (while (and left right (equal (car left) (car right))) + (setq count (1+ count) + left (cdr left) + right (cdr right))) + count)) + +(defun ekp-buffer--discard-live-spans (spans) + "Discard live SPANS and unregister them." + (dolist (span spans) + (setq ekp-buffer--spans (delq span ekp-buffer--spans)) + (ekp-buffer--discard-span span))) + +(defun ekp-buffer--live-split-spans (count) + "Keep COUNT live spans and return the dropped suffix." + (let* ((spans (ekp-buffer--live-state-spans ekp-buffer--live-state)) + (keep-count (min count (length spans))) + (keep (cl-subseq spans 0 keep-count)) + (drop (nthcdr keep-count spans))) + (setf (ekp-buffer--live-state-spans ekp-buffer--live-state) keep) + drop)) + +(defun ekp-buffer--live-prefix-end-after (beg count) + "Return the buffer end of COUNT kept live lines, or BEG." + (if (zerop count) + beg + (marker-position + (ekp-buffer--span-end + (nth (1- count) + (ekp-buffer--live-state-spans ekp-buffer--live-state)))))) + +(defun ekp-buffer--live-span-index-at (position) + "Return the live prefix span index owning POSITION, or nil." + (let ((spans (ekp-buffer--live-state-spans ekp-buffer--live-state)) + found) + (cl-loop for span in spans + for index from 0 + until found + do + (let ((beg (marker-position (ekp-buffer--span-beg span))) + (end (marker-position (ekp-buffer--span-end span)))) + (when (and beg end (<= beg position) (< position end)) + (setq found index)))) + found)) + +(defun ekp-buffer--clear-live-projection (beg end) + "Clear the current live prefix from BEG through END." + (ekp-buffer--discard-live-spans + (ekp-buffer--live-state-spans ekp-buffer--live-state)) + (setf (ekp-buffer--live-state-spans ekp-buffer--live-state) nil) + (let ((ekp-buffer--inhibit t)) + (with-silent-modifications + (save-restriction + (widen) + (ekp-buffer--remove-properties beg end))))) + +(defun ekp-buffer--live-prefix-present-p (beg active) + "Return non-nil when ACTIVE prefix lines from BEG carry EKP ownership." + (or (= active 0) + (let ((prefix-end + (marker-position + (ekp-buffer--live-state-prefix-end ekp-buffer--live-state)))) + (and prefix-end + (not (text-property-any + beg prefix-end 'ekp-justified nil)))))) + +(defun ekp-buffer--unsupported-live-text-p (text) + "Return non-nil for TEXT with whitespace live layout cannot project." + (string-match-p "[\t]" text)) + +(defun ekp-buffer--live-prefix-current-p (beg plan active signatures) + "Return non-nil when PLAN at BEG has ACTIVE installed SIGNATURES." + (and (equal signatures + (ekp-buffer--live-state-signatures ekp-buffer--live-state)) + (eq plan (ekp-buffer--live-state-plan ekp-buffer--live-state)) + (= active (or (ekp-buffer--live-state-active-index + ekp-buffer--live-state) + -1)) + (ekp-buffer--live-prefix-present-p beg active))) + +(defun ekp-buffer--commit-live-state + (source key plan signatures active width prefix-end) + "Commit SOURCE, KEY, PLAN, SIGNATURES, and ACTIVE at WIDTH through PREFIX-END." + (setf (ekp-buffer--live-state-width ekp-buffer--live-state) width + (ekp-buffer--live-state-source ekp-buffer--live-state) source + (ekp-buffer--live-state-key ekp-buffer--live-state) key + (ekp-buffer--live-state-plan ekp-buffer--live-state) plan + (ekp-buffer--live-state-signatures ekp-buffer--live-state) + signatures + (ekp-buffer--live-state-active-index ekp-buffer--live-state) + active) + (set-marker + (ekp-buffer--live-state-prefix-end ekp-buffer--live-state) + prefix-end)) + +(defun ekp-buffer--replace-live-prefix + (key plan beg end width active signatures) + "Replace PLAN prefix at BEG through END and commit KEY. +WIDTH, ACTIVE, and SIGNATURES describe the replacement." + (let* ((old (ekp-buffer--live-state-signatures ekp-buffer--live-state)) + (common (min active + (length (ekp-buffer--live-state-spans + ekp-buffer--live-state)) + (ekp-buffer--common-prefix-length old signatures))) + (clear-start (ekp-buffer--clear-live-suffix beg end common)) + (installed + (ekp-buffer--install-live-prefix + plan beg width common active))) + (setf (ekp-buffer--live-state-spans ekp-buffer--live-state) + (append (ekp-buffer--live-state-spans ekp-buffer--live-state) + (car installed))) + (ekp-buffer--commit-live-state + (ekp-layout-plan-string plan) + key plan signatures active width (or (cdr installed) clear-start)))) + +(defun ekp-buffer--project-live-plan + (key plan beg end width boundary complete) + "Project PLAN before BOUNDARY and commit KEY. +When COMPLETE is non-nil, project every semantic line." + (let* ((active + (if complete + (length (ekp-layout-plan-lines plan)) + (ekp-buffer--active-line-index plan (- boundary beg)))) + (signatures (ekp-buffer--live-prefix-signatures plan active))) + (unless (ekp-buffer--live-prefix-current-p + beg plan active signatures) + (let ((ekp-buffer--inhibit t)) + (ekp-buffer--replace-live-prefix + key plan beg end width active signatures))))) + +(defun ekp-buffer--commit-natural-live-state (beg end width text) + "Commit a natural live state for TEXT from BEG through END at WIDTH." + (ekp-buffer--clear-live-projection beg end) + (ekp-buffer--commit-live-state text nil nil nil 0 width beg)) + +(defun ekp-buffer--clear-live-suffix (beg end keep-count) + "From BEG, keep KEEP-COUNT live lines and clear through END." + (ekp-buffer--discard-live-spans + (ekp-buffer--live-split-spans keep-count)) + (let ((clear-start (or (ekp-buffer--live-prefix-end-after beg keep-count) + beg)) + (ekp-buffer--inhibit t)) + (with-silent-modifications + (save-restriction + (widen) + (ekp-buffer--remove-properties clear-start end))) + clear-start)) + +(defun ekp-buffer--install-live-prefix (plan beg width start active) + "Install PLAN semantic lines START through ACTIVE from BEG at WIDTH." + (let ((lines (ekp-layout-plan-lines plan)) + spans + prefix-end) + (condition-case err + (progn + (with-silent-modifications + (cl-loop for index from start below active + do + (let* ((line (aref lines index)) + (line-beg + (+ beg (ekp-layout-line-source-start line))) + (finish + (+ beg (ekp-buffer--line-break-end line)))) + (ekp-buffer--project-line line plan beg) + (put-text-property + line-beg finish 'ekp-justified width) + (push (ekp-buffer--make-live-line-span + beg line-beg finish width line) + spans) + (setq prefix-end finish)))) + (cons (nreverse spans) prefix-end)) + (error + (ekp-buffer--discard-live-spans spans) + (signal (car err) (cdr err)))))) + +(defun ekp-buffer--publish-semantic-prefix + (beg end width text boundary complete) + "Publish TEXT's stable prefix from BEG to END at WIDTH. +BOUNDARY identifies the native row left natural unless COMPLETE is non-nil." + (cond + ((or (>= beg end) (ekp-buffer--skip-paragraph-p text)) + (ekp-buffer--commit-natural-live-state beg end width text)) + ((> (- end beg) ekp-auto-justify-paragraph-limit) + (ekp-buffer--record-conflict + beg end "paragraph exceeds the automatic paragraph limit") + (ekp-buffer--commit-natural-live-state beg end width text)) + ((and (not complete) + (ekp-buffer--single-line-live-p text width)) + (ekp-buffer--commit-natural-live-state beg end width text)) + ((ekp-buffer--unsupported-live-text-p text) + (ekp-buffer--record-conflict + beg end "unsupported whitespace shrink") + (ekp-buffer--commit-natural-live-state beg end width text)) + ((ekp-buffer--foreign-property beg end) + (ekp-buffer--record-conflict beg end "foreign property in live line") + (ekp-buffer--commit-natural-live-state beg end width text)) + (t + (let* ((entry (ekp-buffer--live-plan-entry text width)) + (key (car entry)) + (plan (cdr entry))) + (if (ekp-buffer--unsupported-gap plan beg) + (progn + (ekp-buffer--record-conflict + beg end "unsupported live whitespace shrink") + (ekp-buffer--commit-natural-live-state beg end width text)) + (ekp-buffer--project-live-plan + key plan beg end width boundary complete)))))) + +(defun ekp-buffer--publish-live-prefix (&optional boundary complete) + "Publish one stable live plan through BOUNDARY. +Keep BOUNDARY's semantic row natural unless COMPLETE is non-nil." + (when ekp-buffer--live-state + (let* ((beg (marker-position + (ekp-buffer--live-state-beg ekp-buffer--live-state))) + (end (marker-position + (ekp-buffer--live-state-end ekp-buffer--live-state))) + (width ekp-buffer--auto-width) + (boundary + (max beg + (min (or boundary + (and ekp-buffer--live-edit + (marker-position + (ekp-buffer--live-edit-edit-end + ekp-buffer--live-edit))) + (point)) + end))) + (text (and beg end (ekp-buffer--logical-substring beg end)))) + (when (and beg end width text) + (condition-case err + (ekp-buffer--publish-semantic-prefix + beg end width text boundary complete) + (error + (ekp-buffer--clear-live-projection beg end) + (signal (car err) (cdr err)))))))) + +(defun ekp-buffer--apply-hard-boundary-edit (beg end) + "Finalize hard paragraphs completed by the edit from BEG through END." + (let* ((edit ekp-buffer--live-edit) + (old-start (marker-position + (ekp-buffer--live-edit-old-beg edit))) + (inserted-newline + (string-match-p + "\n" (buffer-substring-no-properties beg end))) + (active-bounds + (ekp-buffer--para-bounds (cons (point) (point)))) + (active-beg (car active-bounds)) + (active-end (cdr active-bounds))) + (ekp-buffer--release-live-state) + (ekp-buffer--clear-projection old-start active-end) + (when inserted-newline + (let ((ekp-buffer--automatic-pass t)) + (dolist (range (ekp-buffer--paragraph-ranges old-start active-beg)) + (ekp-buffer--layout-paragraph + (car range) (cdr range) ekp-buffer--auto-width)))) + (ekp-buffer--activate-live-paragraph active-beg active-end) + (ekp-buffer--publish-live-prefix end) + (ekp-buffer--release-live-edit))) + +(defun ekp-buffer--baseline-logical-source () + "Return the current transaction baseline without EKP projection." + (ekp-buffer--strip-owned-from-string + (copy-sequence + (ekp-buffer--live-edit-baseline-source ekp-buffer--live-edit)))) + +(defun ekp-buffer--live-baseline-restored-p () + "Return non-nil when source and foreign properties match the baseline." + (let* ((state ekp-buffer--live-state) + (beg (marker-position (ekp-buffer--live-state-beg state))) + (end (marker-position (ekp-buffer--live-state-end state)))) + (and beg end + (equal-including-properties + (ekp-buffer--logical-substring beg end) + (ekp-buffer--baseline-logical-source))))) + +(defun ekp-buffer--baseline-owned-properties (position) + "Return EKP-owned properties at baseline string POSITION." + (let* ((source (ekp-buffer--live-edit-baseline-source + ekp-buffer--live-edit)) + (properties (text-properties-at position source)) + (display (plist-get properties 'ekp-buffer--display)) + (prefix (plist-get properties 'ekp-buffer--line-prefix)) + owned) + (when (plist-member properties 'ekp-justified) + (setq owned + (list 'ekp-justified + (plist-get properties 'ekp-justified)))) + (when display + (setq owned + (append owned + (list 'ekp-buffer--display display + 'display (plist-get properties 'display))))) + (when prefix + (setq owned + (append owned + (list 'ekp-buffer--line-prefix prefix + 'line-prefix + (plist-get properties 'line-prefix))))) + owned)) + +(defun ekp-buffer--restore-baseline-properties (base start finish) + "Restore baseline EKP properties at BASE from START through FINISH." + (let ((source (ekp-buffer--live-edit-baseline-source + ekp-buffer--live-edit)) + (position start)) + (ekp-buffer--naturalize-live-range (+ base start) (+ base finish)) + (let ((ekp-buffer--inhibit t)) + (with-silent-modifications + (while (< position finish) + (let* ((next (or (next-property-change + position source finish) + finish)) + (owned + (ekp-buffer--baseline-owned-properties position))) + (when owned + (add-text-properties + (+ base position) (+ base next) owned)) + (setq position next))))))) + +(defun ekp-buffer--restore-baseline-markers (base) + "Restore every committed marker to its baseline offset from BASE." + (dolist (entry + (ekp-buffer--live-edit-baseline-markers + ekp-buffer--live-edit)) + (set-marker (car entry) (+ base (cdr entry))))) + +(defun ekp-buffer--restore-live-baseline () + "Restore the exact committed projection and close the transaction." + (let* ((edit ekp-buffer--live-edit) + (state ekp-buffer--live-state) + (base (marker-position (ekp-buffer--live-state-beg state)))) + (ekp-buffer--restore-baseline-properties + base + (ekp-buffer--live-edit-dirty-start edit) + (ekp-buffer--live-edit-dirty-finish edit)) + (ekp-buffer--restore-baseline-markers base) + (setf (ekp-buffer--live-state-source state) + (ekp-buffer--baseline-logical-source) + (ekp-buffer--live-state-key state) + (ekp-buffer--live-edit-baseline-key edit) + (ekp-buffer--live-state-plan state) + (ekp-buffer--live-edit-baseline-plan edit) + (ekp-buffer--live-state-signatures state) + (ekp-buffer--live-edit-baseline-signatures edit) + (ekp-buffer--live-state-active-index state) + (ekp-buffer--live-edit-baseline-active-index edit) + (ekp-buffer--live-state-spans state) + (ekp-buffer--live-edit-baseline-spans edit)) + (ekp-buffer--release-live-edit))) + +(defun ekp-buffer--live-row-crossed-p () + "Return non-nil when the edit endpoint crossed its native visual row." + (let* ((edit ekp-buffer--live-edit) + (start (marker-position (ekp-buffer--live-edit-row-start edit))) + (end (marker-position (ekp-buffer--live-edit-edit-end edit))) + (native (and end (ekp-buffer--native-row-start end)))) + (and start end + (if native + (/= native start) + (and (< start end) + (> (ekp--measured-width + (ekp-buffer--logical-substring start end)) + ekp-buffer--auto-width)))))) + +(defun ekp-buffer--ordinary-live-edit-finished (end) + "Advance or retain the current live transaction ending at END." + (cond + ((ekp-buffer--live-baseline-restored-p) + (ekp-buffer--restore-live-baseline)) + ((ekp-buffer--live-row-crossed-p) + (ekp-buffer--publish-live-prefix end) + (ekp-buffer--release-live-edit)))) + +(defun ekp-buffer--retry-composition (buffer generation beg end) + "Retry BUFFER's live edit GENERATION from BEG through END." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq ekp-buffer--composition-timer nil) + (when (and ekp-auto-justify-mode + (= generation ekp-buffer--generation) + ekp-buffer--live-edit) + (if (ekp-buffer--composing-p) + (ekp-buffer--defer-composition beg end) + (ekp-buffer--finish-live-edit beg end)))))) + +(defun ekp-buffer--defer-composition (beg end) + "Defer the current live transaction from BEG through END." + (when (timerp ekp-buffer--composition-timer) + (cancel-timer ekp-buffer--composition-timer)) + (setq ekp-buffer--composition-timer + (run-with-timer + ekp-auto-justify-composition-delay nil + #'ekp-buffer--retry-composition + (current-buffer) ekp-buffer--generation beg end))) + +(defun ekp-buffer--after-change (beg end _old-length) + "Advance the stable live transaction after BEG through END changed." + (when (and ekp-auto-justify-mode + (not ekp-buffer--inhibit) + ekp-buffer--live-edit) + (set-marker + (ekp-buffer--live-edit-edit-end ekp-buffer--live-edit) + end) + (let ((hard-p + (or (string-match-p + "\n" + (ekp-buffer--live-edit-old-fragment + ekp-buffer--live-edit)) + (string-match-p + "\n" (buffer-substring-no-properties beg end))))) + (cond + ((ekp-buffer--composing-p) + (ekp-buffer--defer-composition beg end)) + (hard-p + (ekp-buffer--apply-hard-boundary-edit beg end)) + (t + (ekp-buffer--ordinary-live-edit-finished end)))))) + +(defun ekp-buffer--finish-live-edit (beg end) + "Finish a deferred source edit from BEG through END." + (let ((hard-p + (or (string-match-p + "\n" + (ekp-buffer--live-edit-old-fragment ekp-buffer--live-edit)) + (string-match-p + "\n" (buffer-substring-no-properties beg end))))) + (if hard-p + (ekp-buffer--apply-hard-boundary-edit beg end) + (ekp-buffer--ordinary-live-edit-finished end)))) + +(defun ekp-buffer--make-chunks (beg end) + "Split BEG through END into whole-paragraph marker chunks." + (let (chunks) + (save-excursion + (goto-char beg) + (while (< (point) end) + (let ((start (point)) + (count 0)) + (while (and (< (point) end) + (< count ekp-auto-justify-chunk-size)) + (if (search-forward "\n" end 'move) + (setq count (1+ count)) + (goto-char end))) + (when (< start (point)) + (push (cons (copy-marker start) + (copy-marker (point) t)) + chunks))))) + (nreverse chunks))) + +(defun ekp-buffer--cancel-pending () + "Cancel and discard lazy reflow state." + (when (timerp ekp-buffer--chunk-timer) + (cancel-timer ekp-buffer--chunk-timer)) + (setq ekp-buffer--chunk-timer nil) + (ekp-buffer--release-marker-pairs (cdr ekp-buffer--pending)) + (setq ekp-buffer--pending nil)) + +(defun ekp-buffer--visible-span () + "Return the visible buffer span." + (let ((window (get-buffer-window (current-buffer)))) + (if window + (cons (window-start window) + (or (window-end window t) (point-max))) + (cons (point-min) (point-max))))) + +(defun ekp-buffer--prioritize-visible () + "Move pending chunks intersecting the visible span to the front." + (when (cdr ekp-buffer--pending) + (pcase-let ((`(,beg . ,end) (ekp-buffer--visible-span))) + (let* ((chunks (cdr ekp-buffer--pending)) + (visible (cl-remove-if-not + (lambda (chunk) + (and (< (car chunk) end) (> (cdr chunk) beg))) + chunks))) + (setcdr ekp-buffer--pending + (nconc visible + (cl-set-difference chunks visible :test #'eq))))))) + +(defun ekp-buffer--enqueue-chunks (chunks) + "Queue CHUNKS for lazy processing at the authoritative width." + (when chunks + (ekp-buffer--cancel-pending) + (setq ekp-buffer--pending + (cons ekp-buffer--auto-width chunks)) + (setq ekp-buffer--chunk-timer + (run-with-timer 0.02 nil + #'ekp-buffer--process-chunk (current-buffer))))) + +(defun ekp-buffer--process-one-chunk () + "Project and remove the next pending chunk." + (let ((chunk (cadr ekp-buffer--pending)) + (width (car ekp-buffer--pending))) + (save-restriction + (widen) + (let ((ekp-buffer--automatic-pass t)) + (ekp-justify-region (car chunk) (cdr chunk) width))) + (setcdr ekp-buffer--pending (cddr ekp-buffer--pending)) + (ekp-buffer--release-marker-pairs (list chunk)))) + +(defun ekp-buffer--process-chunk (buffer) + "Process lazy reflow work for BUFFER within the tick budget." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq ekp-buffer--chunk-timer nil) + (if (or (not ekp-auto-justify-mode) + (null ekp-buffer--pending) + (not (eql (car ekp-buffer--pending) + ekp-buffer--auto-width))) + (ekp-buffer--cancel-pending) + (let ((deadline (+ (float-time) ekp-auto-justify-tick-budget)) + (first t)) + (while (and (cdr ekp-buffer--pending) + (or first + (and (< (float-time) deadline) + (not (input-pending-p))))) + (setq first nil) + (ekp-buffer--process-one-chunk)) + (if (cdr ekp-buffer--pending) + (setq ekp-buffer--chunk-timer + (run-with-timer + 0.02 nil #'ekp-buffer--process-chunk buffer)) + (setq ekp-buffer--pending nil))))))) + +(defun ekp-buffer--refresh-live-prefix (&optional boundary complete) + "Restore live state after reflow at BOUNDARY. +Use point only for initial activation. COMPLETE commits every row." + (let ((position (or boundary (point)))) + (pcase-let ((`(,beg . ,end) + (ekp-buffer--para-bounds + (cons position position)))) + (ekp-buffer--activate-live-paragraph beg end)) + (ekp-buffer--publish-live-prefix position complete))) + +(defun ekp-buffer--layout-outside-active + (beg end width active-beg active-end) + "Project BEG through END at WIDTH, excluding the active hard line. +ACTIVE-BEG through ACTIVE-END is published once by the live owner." + (let ((left-end (min end active-beg)) + (right-beg (max beg (if (< active-end (point-max)) + (1+ active-end) + active-end)))) + (when (< beg left-end) + (ekp-justify-region beg left-end width)) + (when (< right-beg end) + (ekp-justify-region right-beg end width)))) + +(defun ekp-buffer--lazy-reflow (width active-beg active-end) + "Reflow visible text at WIDTH and queue text outside the active paragraph. +ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state." + (pcase-let* ((`(,visible-beg . ,visible-end) + (ekp-buffer--visible-span)) + (`(,beg . ,end) + (ekp-buffer--para-bounds + (cons visible-beg visible-end))) + (active-after + (if (< active-end (point-max)) + (1+ active-end) + active-end)) + (ranges (list (cons end (point-max)) + (cons (point-min) beg))) + (chunks nil)) + (ekp-buffer--layout-outside-active + beg end width active-beg active-end) + (dolist (range ranges) + (let ((start (car range)) + (finish (cdr range))) + (when (< start (min finish active-beg)) + (setq chunks + (nconc chunks + (ekp-buffer--make-chunks + start (min finish active-beg))))) + (when (< (max start active-after) finish) + (setq chunks + (nconc chunks + (ekp-buffer--make-chunks + (max start active-after) finish)))))) + (ekp-buffer--enqueue-chunks chunks))) + +(defun ekp-buffer--reflow (buffer width &optional _generation) + "Reflow BUFFER to authoritative WIDTH." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq ekp-buffer--resize-timer nil) + (when ekp-auto-justify-mode + (let* ((complete (and ekp-buffer--live-state t)) + (saved-boundary + (or (and ekp-buffer--live-edit + (marker-position + (ekp-buffer--live-edit-edit-end + ekp-buffer--live-edit))) + (and ekp-buffer--live-state + (marker-position + (ekp-buffer--live-state-prefix-end + ekp-buffer--live-state))) + (point))) + (boundary (max (point-min) + (min saved-boundary (point-max))))) + (setq ekp-buffer--auto-width width) + (ekp-buffer--cancel-pending) + (ekp-buffer--release-live-edit) + (ekp-buffer--detach-live-state) + (save-restriction + (widen) + (pcase-let ((`(,active-beg . ,active-end) + (ekp-buffer--para-bounds + (cons boundary boundary)))) + (ekp-buffer--clear-all) + (let ((ekp-buffer--automatic-pass t)) + (if (< (buffer-size) ekp-auto-justify-lazy-threshold) + (ekp-buffer--layout-outside-active + (point-min) (point-max) width active-beg active-end) + (ekp-buffer--lazy-reflow + width active-beg active-end))))) + (ekp-buffer--refresh-live-prefix boundary complete)))))) + +(defun ekp-buffer--run-scheduled-reflow (buffer) + "Reflow BUFFER using the latest authoritative width." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq ekp-buffer--resize-timer nil) + (when ekp-auto-justify-mode + (ekp-buffer--reflow buffer (ekp-buffer--effective-width buffer)))))) + +(defun ekp-buffer--schedule-reflow () + "Debounce reflow; compute width when the timer fires." + (when ekp-auto-justify-mode + (let ((width (ekp-buffer--effective-width))) + (when (and ekp-buffer--auto-width + (/= width ekp-buffer--auto-width)) + (when (timerp ekp-buffer--resize-timer) + (cancel-timer ekp-buffer--resize-timer)) + (setq ekp-buffer--resize-timer + (run-with-timer + ekp-auto-justify-resize-delay nil + #'ekp-buffer--run-scheduled-reflow + (current-buffer))))))) + +(defun ekp-buffer--on-resize (window-or-frame) + "Schedule reflow for the buffer shown by WINDOW-OR-FRAME." + (let ((window (cond + ((windowp window-or-frame) window-or-frame) + ((framep window-or-frame) + (get-buffer-window + (current-buffer) window-or-frame)) + (t (get-buffer-window (current-buffer)))))) + (when (window-live-p window) + (with-current-buffer (window-buffer window) + (ekp-buffer--schedule-reflow))))) + +(defun ekp-buffer--on-window-change () + "Recheck the authoritative width after a window configuration change." + (ekp-buffer--schedule-reflow)) + +(defun ekp-buffer--on-text-scale () + "Reflow after a text-scale change invalidates glyph metrics." + (when (and ekp-auto-justify-mode ekp-buffer--auto-width) + (ekp-clear-caches) + (ekp-buffer--reflow + (current-buffer) (ekp-buffer--effective-width)))) + +(defun ekp-buffer--on-font-context-change (&optional _theme) + "Rebuild active projections after a theme or frame-font change." + (ekp-clear-caches) + (dolist (buffer (buffer-list)) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when ekp-auto-justify-mode + (setq ekp-buffer--live-plan-cache nil) + (ekp-buffer--reflow + buffer (ekp-buffer--effective-width buffer))))))) + +(dolist (hook '(enable-theme-functions disable-theme-functions + after-setting-font-hook)) + (add-hook hook #'ekp-buffer--on-font-context-change)) + +(defun ekp-buffer--on-scroll (_window _start) + "Prioritize visible lazy work after scrolling." + (when ekp-buffer--pending + (run-with-timer 0 nil + (lambda (buffer) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (ekp-buffer--prioritize-visible)))) + (current-buffer)))) + +(defun ekp-buffer--enable-native-wrap () + "Make native soft wrapping an owned auto-mode precondition." + (unless ekp-buffer--wrap-state + (setq ekp-buffer--wrap-state + (list (local-variable-p 'truncate-lines) + truncate-lines + (local-variable-p 'truncate-partial-width-windows) + truncate-partial-width-windows))) + (setq-local truncate-lines nil) + (setq-local truncate-partial-width-windows nil)) + +(defun ekp-buffer--restore-native-wrap () + "Restore display variables saved by `ekp-buffer--enable-native-wrap'." + (when ekp-buffer--wrap-state + (pcase-let ((`(,lines-local ,lines ,partial-local ,partial) + ekp-buffer--wrap-state)) + (if lines-local + (setq-local truncate-lines lines) + (kill-local-variable 'truncate-lines)) + (if partial-local + (setq-local truncate-partial-width-windows partial) + (kill-local-variable 'truncate-partial-width-windows))) + (setq ekp-buffer--wrap-state nil))) + +(defun ekp-refill-paragraph () + "Apply a complete KP layout to the hard paragraph at point." + (interactive "*") + (ekp-buffer--release-live-edit) + (ekp-buffer--release-live-state) + (pcase-let ((`(,beg . ,end) + (ekp-buffer--para-bounds (cons (point) (point))))) + (ekp-justify-region + beg end (or ekp-buffer--auto-width + (ekp-buffer--window-pixel))))) + +(defvar-keymap ekp-auto-justify-mode-map + :doc "Keymap for `ekp-auto-justify-mode'." + " " #'ekp-refill-paragraph) + +(easy-menu-define ekp-auto-justify-mode-menu ekp-auto-justify-mode-map + "Menu for `ekp-auto-justify-mode'." + '("EKP" + ["Justify Region or Paragraph" ekp-justify-region t] + ["Unjustify Region or Paragraph" ekp-unjustify-region t] + ["Justify Buffer" ekp-justify-buffer t] + ["Unjustify Buffer" ekp-unjustify-buffer t] + "--" + ["Mark Region No-Break" ekp-no-break-region (use-region-p)] + ["Clear No-Break Region" ekp-allow-break-region (use-region-p)] + ["Mark Region Verbatim" ekp-verbatim-region (use-region-p)] + ["Clear Verbatim Region" ekp-clear-verbatim-region (use-region-p)] + "--" + ["Diagnose Layout" ekp-diagnose t])) + +(defun ekp-buffer--set-auto-hooks (enable) + "Install auto-mode hooks when ENABLE is non-nil; otherwise remove them." + (dolist (entry + '((window-size-change-functions . ekp-buffer--on-resize) + (window-configuration-change-hook . ekp-buffer--on-window-change) + (window-scroll-functions . ekp-buffer--on-scroll) + (text-scale-mode-hook . ekp-buffer--on-text-scale) + (after-change-functions . ekp-buffer--after-change) + (change-major-mode-hook . ekp-buffer--teardown))) + (if enable + (add-hook (car entry) (cdr entry) nil t) + (remove-hook (car entry) (cdr entry) t)))) + +(defun ekp-buffer--disable-auto-mode () + "Remove auto-mode hooks and restore the buffer's prior display state." + (ekp-buffer--set-auto-hooks nil) + (ekp-buffer--teardown)) + +(defun ekp-buffer--enable-auto-mode () + "Install the complete auto-mode lifecycle or roll it back on error." + (let (enabled) + (unwind-protect + (progn + (unless (or ekp-buffer-skip-faces ekp-buffer-skip-predicate) + (cond + ((derived-mode-p 'org-mode) (ekp-org-setup)) + ((derived-mode-p 'markdown-mode) (ekp-markdown-setup)))) + (ekp-buffer--enable-native-wrap) + (setq ekp-buffer--auto-width (ekp-buffer--effective-width)) + (ekp-buffer--reflow (current-buffer) ekp-buffer--auto-width) + (ekp-buffer--set-auto-hooks t) + (ekp-buffer--install-integrations) + (setq enabled t)) + (unless enabled + (setq ekp-auto-justify-mode nil) + (ekp-buffer--disable-auto-mode))))) + +;;;###autoload +(define-minor-mode ekp-auto-justify-mode + "Maintain a non-mutating KP display projection. +Completed paragraphs use the authoritative narrowest-window width. +Manual no-break and verbatim properties last only for the current buffer +session." + :lighter " EKP" + :keymap ekp-auto-justify-mode-map + (if ekp-auto-justify-mode + (ekp-buffer--enable-auto-mode) + (ekp-buffer--disable-auto-mode))) + +(defun ekp-buffer--teardown () + "Cancel asynchronous work and remove every EKP projection." + (when (timerp ekp-buffer--resize-timer) + (cancel-timer ekp-buffer--resize-timer)) + (when (timerp ekp-buffer--composition-timer) + (cancel-timer ekp-buffer--composition-timer)) + (ekp-buffer--cancel-pending) + (ekp-buffer--release-live-edit) + (ekp-buffer--release-live-state) + (setq ekp-buffer--resize-timer nil + ekp-buffer--composition-timer nil + ekp-buffer--auto-width nil + ekp-buffer--live-plan-cache nil) + (ekp-buffer--clear-all) + (ekp-buffer--remove-integrations) + (ekp-buffer--restore-native-wrap)) + +;;; Diagnostics + +;;;###autoload +(defun ekp-diagnose () + "Report the authoritative width and any skipped projection conflicts." + (interactive) + (let ((width (ekp-buffer--effective-width)) + (conflicts (length ekp-buffer--conflicts))) + (message + "EKP: authoritative width %dpx (narrowest window), %d conflict%s" + width conflicts (if (= conflicts 1) "" "s")) + (list :width width :conflicts ekp-buffer--conflicts))) + +(provide 'ekp-buffer) + +;;; ekp-buffer.el ends here diff --git a/ekp-region.el b/ekp-region.el deleted file mode 100644 index 955d995..0000000 --- a/ekp-region.el +++ /dev/null @@ -1,1079 +0,0 @@ -;;; ekp-region.el --- Buffer-level justification for ekp -*- lexical-binding: t; -*- - -;; Copyright (C) 2024-2026 Kinney Zhang - -;; Author: Kinney Zhang -;; Keywords: wp, convenience - -;; This file is NOT part of GNU Emacs. - -;; 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 . - -;;; Commentary: - -;; Interactive layer over the ekp string API. -;; -;; - `ekp-justify-region' / `ekp-unjustify-region': justify buffer text -;; in place. Unjustification is a pure structural transform driven by -;; the text properties the renderer leaves behind (`ekp-glue', -;; `ekp-soft-break', `ekp-soft-hyphen', `ekp-hidden'), so the -;; original text — including whitespace runs stripped at line breaks — -;; is recovered exactly, even after the justified text was edited. -;; -;; - `ekp-auto-justify-mode': keeps the whole buffer justified to the -;; window width. Re-flows (debounced) when the window width changes, -;; and incrementally re-justifies only the edited paragraphs after -;; edits, so large buffers stay responsive (unchanged paragraphs hit -;; the ekp paragraph cache). - -;;; Code: - -(require 'ekp) -(require 'cl-lib) -(require 'easymenu) - -(defvar ekp-auto-justify-mode) - -(defgroup ekp-region nil - "Buffer-level justification built on ekp." - :group 'text - :prefix "ekp-") - -(defcustom ekp-region-margin-pixel 2 - "Pixels subtracted from the window body width when justifying. -A small safety margin that keeps justified lines from being wrapped -by the display engine due to rounding." - :type 'natnum) - -(defcustom ekp-auto-justify-resize-delay 0.15 - "Seconds to debounce window resize re-flows in `ekp-auto-justify-mode'." - :type 'number) - -(defcustom ekp-auto-justify-edit-delay 0.3 - "Idle seconds before edited paragraphs are re-justified." - :type 'number) - -(defcustom ekp-auto-justify-lazy-threshold 20000 - "Buffer size (characters) beyond which re-flows go visible-first. -Below it a window width change re-justifies the whole buffer at -once; above it the visible portion is done synchronously and the -rest follows in idle background chunks." - :type 'natnum) - -(defcustom ekp-auto-justify-chunk-size 10 - "Paragraphs re-justified per background chunk in lazy re-flows." - :type 'natnum) - -(defcustom ekp-auto-justify-tick-budget 0.005 - "Seconds of work per background tick in lazy re-flows. -Each tick processes chunks until the budget is exhausted (at least -one), then yields back to the command loop." - :type 'number) - -(defconst ekp-region-org-skip-faces - '(org-block org-block-begin-line org-block-end-line org-code - org-verbatim org-table org-meta-line) - "Reasonable `ekp-region-skip-faces' preset for Org buffers.") - -(defconst ekp-region-markdown-skip-faces - '(markdown-code-face markdown-inline-code-face markdown-pre-face - markdown-table-face) - "Reasonable `ekp-region-skip-faces' preset for Markdown buffers.") - -(defcustom ekp-region-skip-faces nil - "Faces whose paragraphs are never justified (kept verbatim). -Point major-mode faces here — e.g. `org-block' and `org-code' for -Org, `markdown-code-face' for Markdown — and code blocks pass -through untouched. Checked against the `face' property of each -paragraph, symbol or list." - :type '(repeat face)) - -;;;###autoload -(defun ekp-org-setup () - "Configure the current (Org) buffer for ekp justification. -Protects source blocks, tables and meta lines from justification. -Typical use: (add-hook \\='org-mode-hook #\\='ekp-org-setup)." - (setq-local ekp-region-skip-faces ekp-region-org-skip-faces)) - -;;;###autoload -(defun ekp-markdown-setup () - "Configure the current (Markdown) buffer for ekp justification. -Protects code faces from justification, and stops markdown-mode's -font-lock from managing the `display' property — refontification -would otherwise strip the pixel-glue display specs and wreck the -layout. The cost: markdown's own display-based decorations (URL -hiding) are no longer cleaned up by refontification here. -Typical use: (add-hook \\='markdown-mode-hook #\\='ekp-markdown-setup)." - (setq-local ekp-region-skip-faces ekp-region-markdown-skip-faces) - (when (boundp 'font-lock-extra-managed-props) - (setq-local font-lock-extra-managed-props - (remq 'display font-lock-extra-managed-props)))) - -(defvar-local ekp-region-skip-predicate nil - "When non-nil, a function called with a paragraph string. -Return non-nil to keep that paragraph verbatim (no justification). -The general escape hatch for mode-specific block detection; prefer -the `ekp-verbatim' text property or `ekp-region-skip-faces' when -they suffice.") - -(defvar ekp-region--inhibit nil - "Non-nil while ekp-region is modifying the buffer itself.") - -(defvar-local ekp-region--write-buffer nil - "Hidden logical-text buffer used while writing justified content.") - -(defvar-local ekp-region--write-source nil - "Source buffer for an `ekp-region--write-buffer'.") - -(defvar-local ekp-region--previous-filter nil - "Substring filter wrapped by the EKP logical-text filter.") - -(defvar-local ekp-region--previous-filter-local-p nil - "Whether `ekp-region--previous-filter' was buffer-local.") - -(defvar-local ekp-region--filter-installed nil - "Non-nil while EKP owns `filter-buffer-substring-function'.") - -(defmacro ekp-region--preserving-modified (&rest body) - "Run BODY, keeping the buffer unmodified if it was unmodified. -Justification is a reversible re-layout of the same logical text, so -it must not flip `buffer-modified-p' on its own — that would create -lock files, trigger auto-saves and \"buffer modified\" prompts for -buffers the user never edited." - (declare (indent 0) (debug t)) - `(let ((ekp-region--modified-was (buffer-modified-p))) - (prog1 (progn ,@body) - (unless ekp-region--modified-was - (restore-buffer-modified-p nil))))) - -(defvar-local ekp-region--auto-width nil - "Pixel width the buffer is currently auto-justified to.") - -(defvar-local ekp-region--resize-timer nil) -(defvar-local ekp-region--edit-timer nil) -(defvar-local ekp-region--dirty nil - "Pending edited regions, as a list of (BEG-MARKER . END-MARKER).") - -(defvar-local ekp-region--pending nil - "Lazy re-flow state: (WIDTH . CHUNKS), CHUNKS = ((BEG-M . END-M)...).") - -(defvar-local ekp-region--chunk-timer nil) - -;;;; Width - -(defun ekp-region-protrusion-reserve () - "Pixels reserved at the right margin for hanging punctuation. -Non-zero only while `ekp-protrusion' is enabled: protruding glyphs -extend past the flush edge, so the layout width must leave room." - (if ekp-protrusion - (max 2 (ceiling (* (alist-get 'cjk-close ekp-protrusion-ratios 0.5) - (ekp--measured-width "。")))) - 0)) - -(defun ekp-region--indicator-reserve (&optional window) - "Pixels the truncation/continuation indicator eats in WINDOW. -With no right fringe (and on text terminals), Emacs draws the `$' -or `\\' indicator in the text area's LAST COLUMN — a line that -fills the body width exactly gets its final glyph displaced and -every justified line appears truncated. Reserve that column; with -a right fringe the indicator lives in the fringe and costs nothing." - (let ((win (or window (selected-window)))) - (if (and (display-graphic-p (window-frame win)) - (> (or (cadr (window-fringes win)) 0) 0)) - 0 - (frame-char-width (window-frame win))))) - -(defun ekp-region--window-pixel (&optional window) - "Usable text width in pixels of WINDOW (default: selected window)." - (max 1 (- (window-body-width window t) - ekp-region-margin-pixel - (ekp-region--indicator-reserve window) - (ekp-region-protrusion-reserve)))) - -(defun ekp-region--effective-width (&optional buffer) - "Justification width for BUFFER: the narrowest window showing it. -With the buffer in several windows only one width can be laid out; -the narrowest keeps every window free of overflow-wrapped lines. -Falls back to the selected window when the buffer is not displayed." - (let ((wins (get-buffer-window-list (or buffer (current-buffer)) nil t))) - (if wins - (apply #'min (mapcar #'ekp-region--window-pixel wins)) - (ekp-region--window-pixel)))) - -;;;###autoload -(defun ekp-diagnose () - "Check that ekp's measurement matches this window's real rendering. -Justifies a probe line to the window width, renders it invisibly in -this buffer, and compares the rendered pixel width against the -target. A mismatch means glyph metrics differ between measurement -and display (e.g. a face-remapping ekp does not see) and justified -lines would come out over- or under-full." - (interactive) - (let* ((win (or (get-buffer-window (current-buffer)) (selected-window))) - (target (ekp-region--window-pixel win)) - (probe (ekp-pixel-justify - (concat "汉字排版像素精确性探针,中英混排 probe line with " - "Latin words, 标点。悬挂?以及 hyphenation-ready " - "vocabulary examples 结尾。") - target)) - (line (car (split-string probe "\n"))) - (rendered - (with-silent-modifications - (let ((beg (point-max))) - (unwind-protect - (progn - (goto-char beg) - (insert "\n" line) - (car (window-text-pixel-size win (1+ beg) (point-max)))) - (delete-region beg (point-max)))))) - (delta (- rendered target))) - (message (concat "ekp-diagnose: target %dpx, rendered %dpx (Δ%+d) — %s" - (if (buffer-local-value 'face-remapping-alist - (current-buffer)) - " [buffer has face remappings]" "")) - target rendered delta - (if (<= (abs delta) ekp-region-margin-pixel) - "OK, measurement matches rendering" - "MISMATCH: justified lines will not fit this window")) - delta)) - -;;;; Pure string transforms - -(defun ekp-region--split-hard (string) - "Split justified STRING on hard newlines (those without `ekp-soft-break')." - (let ((parts nil) (start 0) (i 0) (len (length string))) - (while (< i len) - (when (and (eq (aref string i) ?\n) - (not (get-text-property i 'ekp-soft-break string))) - (push (substring string start i) parts) - (setq start (1+ i))) - (setq i (1+ i))) - (push (substring string start) parts) - (nreverse parts))) - -(defun ekp-region--face-hit-p (string) - "Non-nil when STRING carries any face from `ekp-region-skip-faces'." - (let ((pos 0) (len (length string)) hit) - (while (and (not hit) (< pos len)) - (let ((f (get-text-property pos 'face string))) - (when (if (listp f) - (seq-intersection f ekp-region-skip-faces) - (memq f ekp-region-skip-faces)) - (setq hit t)) - (setq pos (or (next-single-property-change pos 'face string len) - len)))) - hit)) - -(defun ekp-region--skip-para-p (para) - "Non-nil when the paragraph string PARA must stay verbatim. -Code blocks and other protected text: marked with the `ekp-verbatim' -property, matching `ekp-region-skip-faces', or accepted by -`ekp-region-skip-predicate'." - (or (text-property-not-all 0 (length para) 'ekp-verbatim nil para) - ;; Structured buffer text (comint/eshell prompts, forms) must - ;; never be re-written: fields and read-only spans stay put. - (text-property-not-all 0 (length para) 'field nil para) - (text-property-not-all 0 (length para) 'read-only nil para) - (and ekp-region-skip-faces (ekp-region--face-hit-p para)) - (and ekp-region-skip-predicate - (funcall ekp-region-skip-predicate para)))) - -(defun ekp-region--justify-string (text pixel) - "Return TEXT justified to PIXEL with exact-recovery markers. -Hard newlines are preserved one-to-one. Whitespace-only paragraphs -\(which the string API would empty out) survive as hidden text; -verbatim paragraphs (see `ekp-region--skip-para-p') pass through -untouched." - (let* ((paras (split-string text "\n")) - (skips (mapcar #'ekp-region--skip-para-p paras)) - (cores (cl-loop for p in paras for s in skips - unless (or s (string-blank-p p)) collect p)) - (out (and cores - (ekp-region--split-hard - (ekp-pixel-justify (string-join cores "\n") pixel))))) - (unless (= (length out) (length cores)) - (error "Paragraph count mismatch (%d vs %d)" - (length out) (length cores))) - (string-join - (cl-loop for p in paras for s in skips - collect (cond (s p) - ((string-blank-p p) (ekp--hide-string p)) - (t (pop out)))) - "\n"))) - -(defun ekp-region--pos-for-offset (string offset) - "Physical position in justified STRING for logical OFFSET. -Glue characters count for the length of the original text they -replaced (their `ekp-glue' value), soft breaks for their payload, -soft hyphens for nothing; everything else (including hidden text) -is one logical character." - (let ((i 0) (len (length string))) - (while (and (< i len) (> offset 0)) - (let ((glue (get-text-property i 'ekp-glue string))) - (cond - (glue - (setq offset (- offset (length glue)))) - ((get-text-property i 'ekp-soft-hyphen string)) - ((and (eq (aref string i) ?\n) - (get-text-property i 'ekp-soft-break string)) - (setq offset (- offset (length (get-text-property - i 'ekp-soft-break string))))) - (t (setq offset (1- offset))))) - (setq i (1+ i))) - i)) - -;;;; Commands - -(defun ekp-region--dwim-bounds () - "Region bounds when the region is active, else the paragraph at point." - (if (use-region-p) - (cons (region-beginning) (region-end)) - (ekp-region--para-bounds (cons (point) (point))))) - -;;;###autoload -(defun ekp-justify-region (beg end &optional pixel) - "Justify the text between BEG and END to PIXEL width. -PIXEL defaults to the window text width (see `ekp-region-margin-pixel'); -interactively, a numeric prefix argument supplies it explicitly. -Already-justified text is unjustified first, so the command is -idempotent and can re-flow to a new width." - (interactive - (progn - (barf-if-buffer-read-only) - (pcase-let ((`(,beg . ,end) (ekp-region--dwim-bounds))) - (list beg end - (and current-prefix-arg - (prefix-numeric-value current-prefix-arg)))))) - (setq pixel (or pixel (ekp-region--window-pixel))) - (when (and font-lock-mode - (or ekp-region-skip-faces ekp-region-skip-predicate)) - ;; Face-based verbatim detection needs real faces: parts of the - ;; region jit-lock never displayed are not fontified yet. - (font-lock-ensure (min beg end) (max beg end))) - (let ((beg (copy-marker (min beg end))) - (end (copy-marker (max beg end) t)) - (ekp-region--inhibit t) - (inhibit-read-only t)) - (unwind-protect - (ekp-region--preserving-modified - (atomic-change-group - ;; Re-flow support: strip previous justification first. - (when (text-property-not-all beg end 'ekp-justified nil) - (ekp-region--unjustify-region beg end)) - (let* ((text (buffer-substring beg end)) - (justified (ekp-region--justify-string text pixel)) - (point-offset (and (>= (point) beg) (< (point) end) - (- (point) beg)))) - (unless (equal-including-properties text justified) - (goto-char beg) - (delete-region beg end) - (insert justified) - (when point-offset - (goto-char (+ beg (ekp-region--pos-for-offset - justified point-offset))))) - (add-text-properties beg end (list 'ekp-justified pixel)))) - (ekp-region--install-integrations)) - (set-marker beg nil) - (set-marker end nil)))) - -(defun ekp-region--after-layout-change (&rest _ignored) - "Remove integrations after external edits delete the final layout span." - (unless (or ekp-region--inhibit - ekp-auto-justify-mode - (ekp-region--justified-spans)) - (ekp-region--remove-integrations))) - -(defun ekp-region--install-integrations () - "Install the buffer-local hooks justified text depends on. -Idempotent; added by `ekp-justify-region' and `ekp-auto-justify-mode'." - ;; Saving writes a logical copy without mutating the display buffer. - (add-hook 'write-region-annotate-functions - #'ekp-region--write-logical-buffer nil t) - (add-hook 'kill-buffer-hook #'ekp-region--discard-write-buffer nil t) - ;; Isearch searches the logical text. - (add-hook 'isearch-mode-hook #'ekp-region--isearch-begin nil t) - (add-hook 'isearch-mode-end-hook #'ekp-region--isearch-end nil t) - (add-hook 'after-change-functions #'ekp-region--after-layout-change nil t) - ;; The kill ring receives the logical text. - (unless ekp-region--filter-installed - (setq ekp-region--previous-filter-local-p - (local-variable-p 'filter-buffer-substring-function) - ekp-region--previous-filter filter-buffer-substring-function - ekp-region--filter-installed t) - (setq-local filter-buffer-substring-function - #'ekp-region--filter-buffer-substring))) - -(defun ekp-region--remove-integrations () - "Remove the hooks installed by `ekp-region--install-integrations'." - (remove-hook 'write-region-annotate-functions - #'ekp-region--write-logical-buffer t) - (remove-hook 'kill-buffer-hook #'ekp-region--discard-write-buffer t) - (ekp-region--discard-write-buffer) - (remove-hook 'isearch-mode-hook #'ekp-region--isearch-begin t) - (remove-hook 'isearch-mode-end-hook #'ekp-region--isearch-end t) - (remove-hook 'after-change-functions #'ekp-region--after-layout-change t) - (when ekp-region--filter-installed - (when (eq filter-buffer-substring-function - #'ekp-region--filter-buffer-substring) - (if ekp-region--previous-filter-local-p - (setq-local filter-buffer-substring-function - ekp-region--previous-filter) - (kill-local-variable 'filter-buffer-substring-function))) - (setq ekp-region--previous-filter nil - ekp-region--previous-filter-local-p nil - ekp-region--filter-installed nil))) - -(defun ekp-region--unjustify-region (beg end) - "Restore the logical text between BEG and END. -Removes synthesized glue and soft hyphens, replaces soft line breaks -with the whitespace they swallowed, and re-exposes hidden paragraph -tails. Text the user typed into the justified region is preserved." - (let ((end-m (copy-marker (max beg end) t)) - (ekp-region--inhibit t) - (inhibit-read-only t)) - (unwind-protect - (ekp-region--preserving-modified - (save-excursion - (goto-char (min beg end)) - (while (< (point) end-m) - (let* ((pos (point)) - (glue (get-text-property pos 'ekp-glue))) - (cond - (glue - (delete-region pos (1+ pos)) - (when (stringp glue) (insert glue))) - ((get-text-property pos 'ekp-soft-hyphen) - (delete-region pos (1+ pos))) - ((and (eq (char-after pos) ?\n) - (get-text-property pos 'ekp-soft-break)) - (let ((payload (get-text-property pos 'ekp-soft-break))) - (delete-region pos (1+ pos)) - (insert payload))) - ((get-text-property pos 'ekp-hidden) - (remove-text-properties pos (1+ pos) - '(ekp-hidden nil display nil)) - (forward-char 1)) - ;; Plain text: our markers are sparse, so hop straight - ;; to the next property boundary instead of stepping - ;; char by char. - (t (goto-char (min (marker-position end-m) - (next-property-change pos nil - (marker-position - end-m)))))))) - (remove-text-properties (min beg end) end-m '(ekp-justified nil)))) - (set-marker end-m nil)))) - -;;;###autoload -(defun ekp-unjustify-region (beg end) - "Restore the logical text between BEG and END. -When the final justified span disappears outside auto mode, remove -the buffer integrations that no longer have a layout to serve." - (interactive - (progn - (barf-if-buffer-read-only) - (pcase-let ((`(,beg . ,end) (ekp-region--dwim-bounds))) - (list beg end)))) - (ekp-region--unjustify-region beg end) - (unless (or ekp-auto-justify-mode (ekp-region--justified-spans)) - (ekp-region--remove-integrations))) - -;;;; Saving: write a logical copy, never mutate the display buffer - -(defun ekp-region--justified-spans () - "Return justified spans of the buffer as a list of (BEG END WIDTH). -BEG/END are positions; WIDTH is the span's `ekp-justified' value." - (let ((pos (point-min)) spans) - (while (< pos (point-max)) - (let ((w (get-text-property pos 'ekp-justified)) - (next (next-single-property-change pos 'ekp-justified - nil (point-max)))) - (when w (push (list pos next w) spans)) - (setq pos next))) - (nreverse spans))) - -(defun ekp-region--discard-write-buffer () - "Discard the current buffer's pending logical write buffer." - (when (buffer-live-p ekp-region--write-buffer) - (with-current-buffer ekp-region--write-buffer - (set-buffer-modified-p nil) - (let ((kill-buffer-query-functions nil)) - (kill-buffer)))) - (setq ekp-region--write-buffer nil)) - -(defun ekp-region--finish-write-buffer () - "Dispose the current logical write buffer after a successful write." - (let ((source ekp-region--write-source) - (write-buffer (current-buffer))) - (when (buffer-live-p source) - (with-current-buffer source - (when (eq ekp-region--write-buffer write-buffer) - (setq ekp-region--write-buffer nil)))) - (set-buffer-modified-p nil) - (let ((kill-buffer-query-functions nil)) - (kill-buffer write-buffer)))) - -(defun ekp-region--make-write-buffer () - "Return a logical-text copy of the current justified buffer." - (ekp-region--discard-write-buffer) - (let ((source (current-buffer)) - (selective selective-display) - (multibyte enable-multibyte-characters) - (coding buffer-file-coding-system) - (write-buffer (generate-new-buffer " *ekp-logical-write*"))) - (setq ekp-region--write-buffer write-buffer) - (with-current-buffer write-buffer - (set-buffer-multibyte multibyte) - (setq selective-display selective - buffer-file-coding-system coding - ekp-region--write-source source) - (insert-buffer-substring source) - (ekp-region--unjustify-region (point-min) (point-max)) - (set-buffer-modified-p nil) - (setq-local write-region-post-annotation-function - #'ekp-region--finish-write-buffer)) - write-buffer)) - -(defun ekp-region--write-logical-buffer (start end) - "Switch whole-buffer writes to a logical copy. -START and END are the `write-region-annotate-functions' arguments. -Region-only writes retain their normal physical-buffer semantics." - (when (and (null start) (null end) (ekp-region--justified-spans)) - (set-buffer (ekp-region--make-write-buffer))) - nil) - -;;;; Isearch: search the logical text - -(defvar-local ekp-region--isearch-state nil - "Spans unjustified while isearch is active: ((BEG-M END-M WIDTH)...).") - -(defun ekp-region--isearch-begin () - "Show the logical text while searching. -Justified layout injects real space characters between CJK glyphs and -splits words across soft breaks and hyphens, so searching the layout -finds almost nothing. The buffer is un-justified for the duration of -the search and restored by `ekp-region--isearch-end'." - (let ((spans (and (null ekp-region--isearch-state) - (ekp-region--justified-spans)))) - (when spans - (let ((buffer-undo-list t)) - (setq ekp-region--isearch-state - (mapcar (pcase-lambda (`(,beg ,end ,width)) - (list (copy-marker beg) (copy-marker end t) width)) - spans)) - (pcase-dolist (`(,beg ,end ,_w) ekp-region--isearch-state) - (ekp-region--unjustify-region beg end)))))) - -(defun ekp-region--isearch-end () - "Restore the justified layout after isearch." - (when ekp-region--isearch-state - (let ((buffer-undo-list t)) - (pcase-dolist (`(,beg ,end ,width) ekp-region--isearch-state) - (when (and (marker-position beg) (marker-position end)) - (ekp-justify-region beg end width)) - (set-marker beg nil) - (set-marker end nil))) - (setq ekp-region--isearch-state nil))) - -;;;; Kill/yank: the kill ring receives the logical text - -(defun ekp-region--logical-string (string) - "Return STRING with any ekp layout markers structurally inverted. -Non-justified strings are returned unchanged (same object)." - (if (cl-some (lambda (prop) - (text-property-not-all 0 (length string) prop nil string)) - ekp--layout-marker-properties) - (with-temp-buffer - (insert string) - (ekp-region--unjustify-region (point-min) (point-max)) - (buffer-string)) - string)) - -(defun ekp-region--filter-buffer-substring (beg end &optional delete) - "Extract BEG..END for the kill ring as logical text. -Killing justified text and yanking it elsewhere must transport the -words, not the pixel layout of the source window (DELETE as in -`filter-buffer-substring-function')." - (let ((ekp-region--inhibit t) - extracted) - (unwind-protect - (let ((filter-buffer-substring-function ekp-region--previous-filter)) - (setq extracted (filter-buffer-substring beg end delete))) - (when delete - (let ((ekp-region--inhibit nil)) - (ekp-region--after-layout-change)))) - (ekp-region--logical-string extracted))) - -;;;###autoload -(defun ekp-justify-buffer (&optional pixel) - "Justify the whole accessible portion of the buffer to PIXEL width. -PIXEL defaults to the window text width; interactively, a numeric -prefix argument supplies it explicitly." - (interactive - (progn - (barf-if-buffer-read-only) - (list (and current-prefix-arg - (prefix-numeric-value current-prefix-arg))))) - (ekp-justify-region (point-min) (point-max) pixel)) - -;;;###autoload -(defun ekp-unjustify-buffer () - "Restore the logical text of the whole accessible portion." - (interactive "*") - (ekp-unjustify-region (point-min) (point-max))) - -;;;###autoload -(defun ekp-no-break-region (beg end &optional announce) - "Mark the region from BEG to END as an unbreakable typesetting atom. -Justification treats it as one rigid unit: no line break inside, no -hyphenation, spacing stays literal (inline code, product names, -numbers with units). This text property lasts only for the current -buffer session; plain-text saving does not persist it. ANNOUNCE -requests interactive feedback." - (interactive (ekp-region--interactive-protection-args)) - (ekp-region--set-protection beg end 'ekp-no-break t "Marked no-break" - announce)) - -;;;###autoload -(defun ekp-allow-break-region (beg end &optional announce) - "Remove session-local `ekp-no-break' marking between BEG and END. -ANNOUNCE requests interactive feedback." - (interactive (ekp-region--interactive-protection-args)) - (ekp-region--set-protection beg end 'ekp-no-break nil "Cleared no-break" - announce)) - -;;;###autoload -(defun ekp-verbatim-region (beg end &optional announce) - "Protect the paragraphs from BEG to END against justification (code blocks). -Whole paragraphs carrying the `ekp-verbatim' property pass through -`ekp-justify-region' and `ekp-auto-justify-mode' untouched. For an -unbreakable span inside prose, use `ekp-no-break-region' instead. -This text property lasts only for the current buffer session; -plain-text saving does not persist it. ANNOUNCE requests interactive -feedback." - (interactive (ekp-region--interactive-protection-args)) - (ekp-region--set-protection beg end 'ekp-verbatim t "Marked verbatim" - announce)) - -;;;###autoload -(defun ekp-clear-verbatim-region (beg end &optional announce) - "Remove session-local `ekp-verbatim' protection between BEG and END. -ANNOUNCE requests interactive feedback." - (interactive (ekp-region--interactive-protection-args)) - (ekp-region--set-protection beg end 'ekp-verbatim nil "Cleared verbatim" - announce)) - -(defun ekp-region--interactive-protection-args () - "Return region arguments for an interactive protection command." - (barf-if-buffer-read-only) - (list (region-beginning) (region-end) t)) - -(defun ekp-region--set-protection (beg end property enabled label announce) - "Set PROPERTY to ENABLED from BEG to END and optionally ANNOUNCE LABEL." - (if enabled - (add-text-properties beg end (list property t)) - (remove-text-properties beg end (list property nil))) - (when announce - (message "EKP: %s on %d characters; current buffer session only" - label (- end beg)))) - -;;;; Auto-justify minor mode - -(defun ekp-region--para-bounds (marker-pair) - "Hard-paragraph bounds containing MARKER-PAIR, as (BEG . END)." - (let ((b (let ((x (car marker-pair))) - (if (markerp x) (marker-position x) x))) - (e (let ((x (cdr marker-pair))) - (if (markerp x) (marker-position x) x)))) - (save-excursion - (goto-char (max (point-min) (min b (point-max)))) - (while (and (> (point) (point-min)) - (let ((prev (1- (point)))) - (not (and (eq (char-after prev) ?\n) - (not (get-text-property prev 'ekp-soft-break)))))) - (forward-char -1)) - (setq b (point)) - (goto-char (max (point-min) (min e (point-max)))) - (while (and (< (point) (point-max)) - (not (and (eq (char-after) ?\n) - (not (get-text-property (point) 'ekp-soft-break))))) - (forward-char 1)) - (cons b (point))))) - -(defun ekp-region--merge-regions (regions) - "Merge overlapping or adjacent (BEG . END) REGIONS." - (let ((sorted (sort regions (lambda (a b) (< (car a) (car b))))) - merged) - (dolist (r sorted) - (if (and merged (<= (car r) (cdr (car merged)))) - (setcdr (car merged) (max (cdr (car merged)) (cdr r))) - (push (cons (car r) (cdr r)) merged))) - (nreverse merged))) - -(defun ekp-region--after-change (beg end _len) - "Record the edit between BEG and END for incremental re-justification. -Changes applied by undo are not re-flowed: re-justifying behind the -user's back would fight the undo sequence (and immediately dirty what -undo just restored). The next real edit or resize re-flows normally." - (when (and ekp-auto-justify-mode (not ekp-region--inhibit) - (not undo-in-progress)) - (push (cons (copy-marker beg) (copy-marker end)) ekp-region--dirty) - (when (timerp ekp-region--edit-timer) - (cancel-timer ekp-region--edit-timer)) - (setq ekp-region--edit-timer - (run-with-idle-timer ekp-auto-justify-edit-delay nil - #'ekp-region--flush-dirty (current-buffer))))) - -(defun ekp-region--composing-p () - "Non-nil while an input method composition (quail preedit) is active. -Re-flowing the buffer under a live preedit overlay corrupts the -composition the user is still typing." - (and (bound-and-true-p quail-overlay) - (overlayp quail-overlay) - (overlay-buffer quail-overlay))) - -(defun ekp-region--flush-dirty (buffer) - "Re-justify the paragraphs of BUFFER touched by recent edits." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (if (ekp-region--composing-p) - ;; Let the user finish composing; try again after the delay. - (setq ekp-region--edit-timer - (run-with-idle-timer ekp-auto-justify-edit-delay nil - #'ekp-region--flush-dirty buffer)) - (when (and ekp-auto-justify-mode ekp-region--dirty ekp-region--auto-width) - (let* ((pairs (prog1 ekp-region--dirty (setq ekp-region--dirty nil))) - ;; Convert all bounds to markers before the first - ;; re-justification shifts later positions. - (regions (mapcar (lambda (r) - (cons (copy-marker (car r)) - (copy-marker (cdr r) t))) - (ekp-region--merge-regions - (mapcar #'ekp-region--para-bounds pairs)))) - (total (cl-reduce #'+ regions - :key (lambda (r) (- (cdr r) (car r))) - :initial-value 0))) - (if (> total ekp-auto-justify-lazy-threshold) - ;; A huge dirty area (big paste, revert): chunk it like - ;; a lazy re-flow instead of freezing the command loop. - (progn - (ekp-region--enqueue-chunks - (mapcan (lambda (r) - (prog1 (ekp-region--make-chunks (car r) (cdr r)) - (set-marker (car r) nil) - (set-marker (cdr r) nil))) - regions)) - (ekp-region--prioritize-visible)) - (dolist (r regions) - (ekp-justify-region (car r) (cdr r) ekp-region--auto-width) - (set-marker (car r) nil) - (set-marker (cdr r) nil))) - (dolist (p pairs) - (set-marker (car p) nil) - (set-marker (cdr p) nil)))))))) - -(defun ekp-region--enqueue-chunks (chunks) - "Queue CHUNKS for background processing at the current auto width. -Prepends to an existing queue at the same width (edits win over the -tail of a resize re-flow); anything queued for a stale width was -already superseded and is dropped." - (when chunks - (if (and ekp-region--pending - (eql (car ekp-region--pending) ekp-region--auto-width)) - (setcdr ekp-region--pending - (nconc chunks (cdr ekp-region--pending))) - (ekp-region--cancel-pending) - (setq ekp-region--pending (cons ekp-region--auto-width chunks))) - (unless (timerp ekp-region--chunk-timer) - (setq ekp-region--chunk-timer - (run-with-timer 0.02 nil #'ekp-region--process-chunk - (current-buffer)))))) - -(defun ekp-region--prioritize-visible () - "Move queued chunks that intersect the visible span to the front. -Scrolling into an unprocessed area should not have to wait for the -whole queue." - (when (cdr ekp-region--pending) - (pcase-let ((`(,vbeg . ,vend) (ekp-region--visible-span))) - (let* ((chunks (cdr ekp-region--pending)) - (vis (cl-remove-if-not - (lambda (c) (and (< (car c) vend) (> (cdr c) vbeg))) - chunks)) - (rest (cl-remove-if - (lambda (c) (memq c vis)) - chunks))) - (setcdr ekp-region--pending (nconc vis rest)))))) - -(defun ekp-region--on-scroll (window _start) - "Re-prioritize the lazy queue after WINDOW scrolled. -Runs off a zero timer: inside `window-scroll-functions' the window's -final extent is not known yet." - (let ((buf (window-buffer window))) - (when (buffer-live-p buf) - (run-with-timer - 0 nil - (lambda () - (when (buffer-live-p buf) - (with-current-buffer buf - (when (and ekp-auto-justify-mode ekp-region--pending) - (ekp-region--prioritize-visible))))))))) - -(defun ekp-region--on-resize (window-or-frame) - "Debounced re-flow after WINDOW-OR-FRAME changed size. -Buffer-local members of `window-size-change-functions' receive the -window showing the buffer — and are not guaranteed to run with that -buffer current — so resolve both explicitly." - (let ((win (cond ((windowp window-or-frame) window-or-frame) - ((framep window-or-frame) - (get-buffer-window (current-buffer) window-or-frame)) - (t (get-buffer-window (current-buffer)))))) - (when (window-live-p win) - (with-current-buffer (window-buffer win) - (ekp-region--schedule-reflow))))) - -(defun ekp-region--on-window-change () - "Re-check the layout width after the window configuration changed. -Catches the buffer becoming displayed (possibly for the first time), -window splits, and deletions of the narrowest window." - (ekp-region--schedule-reflow)) - -(defun ekp-region--on-text-scale () - "Re-flow after a text-scale change. -Scaling remaps the default face, so every glyph metric changed: the -current layout is wrong at the same pixel width and must be re-done -under the new measurement context." - (when (and ekp-auto-justify-mode ekp-region--auto-width) - (when (timerp ekp-region--resize-timer) - (cancel-timer ekp-region--resize-timer)) - (setq ekp-region--resize-timer - (run-with-timer ekp-auto-justify-resize-delay nil - #'ekp-region--reflow - (current-buffer) ekp-region--auto-width)))) - -(defun ekp-region--schedule-reflow () - "Debounce a re-flow of the current buffer to its effective width." - (when ekp-auto-justify-mode - (let ((w (ekp-region--effective-width))) - (when (and ekp-region--auto-width (/= w ekp-region--auto-width)) - (when (timerp ekp-region--resize-timer) - (cancel-timer ekp-region--resize-timer)) - (setq ekp-region--resize-timer - (run-with-timer ekp-auto-justify-resize-delay nil - #'ekp-region--reflow - (current-buffer) w)))))) - -(defun ekp-region--cancel-pending () - "Drop any queued lazy re-flow chunks." - (when (timerp ekp-region--chunk-timer) - (cancel-timer ekp-region--chunk-timer)) - (setq ekp-region--chunk-timer nil) - (dolist (c (cdr ekp-region--pending)) - (set-marker (car c) nil) - (set-marker (cdr c) nil)) - (setq ekp-region--pending nil)) - -(defun ekp-region--make-chunks (beg end) - "Split [BEG, END) into marker-pair chunks of whole hard paragraphs." - (let ((chunks nil)) - (save-excursion - (goto-char beg) - (while (< (point) end) - (let ((cbeg (point)) (paras 0)) - (while (and (< (point) end) - (< paras ekp-auto-justify-chunk-size)) - (if (search-forward "\n" end 'move) - (unless (get-text-property (match-beginning 0) - 'ekp-soft-break) - (setq paras (1+ paras))) - nil)) - (when (> (point) cbeg) - ;; BEG has insertion-type t: the previous chunk's re-insert - ;; happens exactly at this boundary, and the marker must - ;; end up after that text, not before it. - (push (cons (copy-marker cbeg t) (copy-marker (point) t)) - chunks))))) - (nreverse chunks))) - -(defun ekp-region--visible-span () - "Visible portion of the current buffer, as (BEG . END)." - (let ((win (get-buffer-window (current-buffer)))) - (if win - (cons (window-start win) (or (window-end win t) (point-max))) - (cons (point-min) (point-max))))) - -(defun ekp-region--process-chunk (buffer) - "Re-justify the next queued chunk of BUFFER, then reschedule." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (setq ekp-region--chunk-timer nil) - (cond - ((or (not ekp-auto-justify-mode) (null ekp-region--pending)) - (ekp-region--cancel-pending)) - ;; a newer re-flow superseded this queue - ((not (eql (car ekp-region--pending) ekp-region--auto-width)) - (ekp-region--cancel-pending)) - ;; be polite: yield to pending input and live compositions - ((or (input-pending-p) (ekp-region--composing-p)) - (setq ekp-region--chunk-timer - (run-with-timer 0.1 nil #'ekp-region--process-chunk buffer))) - (t - (let ((width (car ekp-region--pending)) - (deadline (+ (float-time) ekp-auto-justify-tick-budget)) - (first t)) - ;; Work until the tick budget runs out — at least one chunk, - ;; never with input waiting. Peek-then-pop: an abort inside - ;; justification must not lose the chunk. - (while (and (cdr ekp-region--pending) - (or first - (and (< (float-time) deadline) - (not (input-pending-p))))) - (setq first nil) - (let ((chunk (cadr ekp-region--pending))) - (ekp-justify-region (car chunk) (cdr chunk) width) - (setcdr ekp-region--pending (cddr ekp-region--pending)) - (set-marker (car chunk) nil) - (set-marker (cdr chunk) nil))) - (if (cdr ekp-region--pending) - (setq ekp-region--chunk-timer - (run-with-timer 0.02 nil - #'ekp-region--process-chunk buffer)) - (setq ekp-region--pending nil)))))))) - -(defun ekp-region--reflow (buffer width) - "Re-justify BUFFER to WIDTH — whole buffer, or visible-first when large." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (when ekp-auto-justify-mode - (setq ekp-region--auto-width width) - (ekp-region--cancel-pending) - (if (< (- (point-max) (point-min)) - ekp-auto-justify-lazy-threshold) - (ekp-justify-region (point-min) (point-max) width) - ;; visible part now, the rest in background chunks - (pcase-let* ((`(,vbeg . ,vend) (ekp-region--visible-span)) - (`(,pbeg . ,pend) - (ekp-region--para-bounds (cons vbeg vend)))) - (ekp-justify-region pbeg pend width) - (let ((chunks (nconc - ;; start at PEND so the hard newline there - ;; gets its ekp-justified property too - (ekp-region--make-chunks pend (point-max)) - (ekp-region--make-chunks (point-min) pbeg)))) - (when chunks - (setq ekp-region--pending (cons width chunks)) - (setq ekp-region--chunk-timer - (run-with-timer 0.02 nil - #'ekp-region--process-chunk - buffer)))))))))) - -(defun ekp-refill-paragraph () - "Re-justify the hard paragraph at point (ekp's `fill-paragraph'). -Bound to \\[fill-paragraph] while `ekp-auto-justify-mode' is on: -plain `fill-paragraph' would treat glue spaces and soft breaks as -content and destroy the original whitespace." - (interactive "*") - (pcase-let ((`(,beg . ,end) - (ekp-region--para-bounds (cons (point) (point))))) - (ekp-justify-region beg end (or ekp-region--auto-width - (ekp-region--window-pixel))))) - -(defvar-keymap ekp-auto-justify-mode-map - :doc "Keymap for `ekp-auto-justify-mode'." - " " #'ekp-refill-paragraph) - -(easy-menu-define ekp-auto-justify-mode-menu ekp-auto-justify-mode-map - "Menu for `ekp-auto-justify-mode'." - '("EKP" - ["Justify Region or Paragraph" ekp-justify-region t] - ["Unjustify Region or Paragraph" ekp-unjustify-region t] - ["Justify Buffer" ekp-justify-buffer t] - ["Unjustify Buffer" ekp-unjustify-buffer t] - "--" - ["Mark Region No-Break" ekp-no-break-region (use-region-p)] - ["Clear No-Break Region" ekp-allow-break-region (use-region-p)] - ["Mark Region Verbatim" ekp-verbatim-region (use-region-p)] - ["Clear Verbatim Region" ekp-clear-verbatim-region (use-region-p)] - "--" - ["Diagnose Window Fit" ekp-diagnose t])) - -;;;###autoload -(define-minor-mode ekp-auto-justify-mode - "Keep the buffer pixel-justified to the window width. -Re-flows when the window width changes and re-justifies edited -paragraphs incrementally. Designed for reading and previewing; -the buffer text is restored exactly when the mode is turned off. - -The EKP menu exposes justify, unjustify, no-break, verbatim, and -diagnostic commands. Manual no-break and verbatim properties last -only for the current buffer session; use mode faces or -`ekp-region-skip-predicate' for protection derived from persistent -document syntax." - :lighter " EKP" - :keymap ekp-auto-justify-mode-map - (if ekp-auto-justify-mode - (progn - ;; Out-of-the-box protection for the common markup modes, - ;; unless the user configured their own. - (unless (or ekp-region-skip-faces ekp-region-skip-predicate) - (cond ((derived-mode-p 'org-mode) (ekp-org-setup)) - ((derived-mode-p 'markdown-mode) (ekp-markdown-setup)))) - (setq ekp-region--auto-width (ekp-region--effective-width)) - (ekp-region--reflow (current-buffer) ekp-region--auto-width) - (add-hook 'window-size-change-functions #'ekp-region--on-resize nil t) - (add-hook 'window-configuration-change-hook - #'ekp-region--on-window-change nil t) - (add-hook 'window-scroll-functions #'ekp-region--on-scroll nil t) - (add-hook 'text-scale-mode-hook #'ekp-region--on-text-scale nil t) - (add-hook 'after-change-functions #'ekp-region--after-change nil t) - (ekp-region--install-integrations) - ;; Turning the major mode off/over kills local hooks silently; - ;; the buffer must get its logical text back first. - (add-hook 'change-major-mode-hook #'ekp-region--teardown nil t)) - (remove-hook 'window-size-change-functions #'ekp-region--on-resize t) - (remove-hook 'window-configuration-change-hook - #'ekp-region--on-window-change t) - (remove-hook 'window-scroll-functions #'ekp-region--on-scroll t) - (remove-hook 'text-scale-mode-hook #'ekp-region--on-text-scale t) - (remove-hook 'after-change-functions #'ekp-region--after-change t) - (remove-hook 'change-major-mode-hook #'ekp-region--teardown t) - (ekp-region--teardown) - ;; The teardown removed all justified text; a later - ;; ekp-justify-region re-installs what it needs. - (ekp-region--remove-integrations))) - -(defun ekp-region--teardown () - "Cancel timers and restore the whole buffer's logical text. -Runs when `ekp-auto-justify-mode' is turned off and, via -`change-major-mode-hook', when a major-mode switch is about to -discard the mode silently." - (when (timerp ekp-region--resize-timer) - (cancel-timer ekp-region--resize-timer)) - (when (timerp ekp-region--edit-timer) - (cancel-timer ekp-region--edit-timer)) - (ekp-region--cancel-pending) - (dolist (p ekp-region--dirty) - (set-marker (car p) nil) - (set-marker (cdr p) nil)) - (setq ekp-region--resize-timer nil - ekp-region--edit-timer nil - ekp-region--dirty nil - ekp-region--auto-width nil) - ;; Narrowing must not leave justified orphans outside the visible - ;; region. - (save-restriction - (widen) - (ekp-region--unjustify-region (point-min) (point-max)))) - -(provide 'ekp-region) - -;;; ekp-region.el ends here diff --git a/ekp.el b/ekp.el index dc2a2a5..4ca5b49 100644 --- a/ekp.el +++ b/ekp.el @@ -158,8 +158,8 @@ A line ending in punctuation lets part of that glyph hang past the flush edge, per `ekp-protrusion-ratios' — CLREQ line-end punctuation squeeze and microtype-style hanging periods/hyphens in one mechanism. Left-edge protrusion is not implemented: Emacs cannot render text -before the line origin. When enabled, reserve the protrusion width -in the layout (see `ekp-region-protrusion-reserve')." +before the line origin. Buffer integration reserves the protrusion +width in its layout when enabled." :type 'boolean :group 'ekp) @@ -241,10 +241,27 @@ when non-zero the C module is bypassed automatically." ;; Lazily memoized (START . END) offsets of each box in the source ;; string (render-time lossless payloads); content-invariant. (box-offsets-memo nil) + ;; Natural pixel width of each source gap (indexed by right box). + ;; Width-independent projection geometry is measured once per paragraph. + (gap-naturals-memo nil) ;; Glue params snapshot at para creation time (plist) glue-params (dp-cache nil :type hash-table)) +(cl-defstruct (ekp-layout-gap (:constructor ekp-layout-gap--create)) + "One planned glue between two source boxes." + kind left-box right-box source-start source-end natural-pixel target-pixel) + +(cl-defstruct (ekp-layout-line (:constructor ekp-layout-line--create)) + "One display line in an `ekp-layout-plan'." + index box-start box-end source-start source-end glues gaps + leading-pixel trailing-pixel hyphen-p break-kind + break-source-start break-source-end signature) + +(cl-defstruct (ekp-layout-plan (:constructor ekp-layout-plan--create)) + "Semantic KP layout independent of any output representation." + string line-pixel boxes offsets lines) + (defvar ekp--para-cache nil "Cache: equal-keyed table, content key → ekp-para struct.") @@ -606,14 +623,6 @@ Positions right after HYPHEN-POSITIONS are forced to `nws'." ((eq 'mws type) (plist-get params :mws-ideal)) ((eq 'cws type) (plist-get params :cws-ideal))))) -(defun ekp--para-glue-shrink (para type) - "Get shrink amount for TYPE using PARA's stored glue params." - (let ((params (ekp-para-glue-params para))) - (cond ((or (null type) (eq 'nws type)) 0) - ((eq 'lws type) (plist-get params :lws-shrink)) - ((eq 'mws type) (plist-get params :mws-shrink)) - ((eq 'cws type) (plist-get params :cws-shrink))))) - (defun ekp--para-glue-stretch (para type) "Get stretch amount for TYPE using PARA's stored glue params." (let ((params (ekp-para-glue-params para))) @@ -1647,21 +1656,22 @@ HYPHEN-COUNT)." ekp-consecutive-hyphen-penalty (float ekp-last-line-short-penalty) (if (eq ekp-alignment 'justify) - 0 + 0 (ekp--ragged-extra-stretch))))) +(defun ekp--dp-cache-para (para line-pixel) + "Return PARA's DP result at LINE-PIXEL, computing it when absent." + (or (ekp--dp-get-cached para line-pixel) + (if (ekp--c-available-p) + (ekp--dp-cache-via-c para line-pixel) + (ekp--dp-cache-elisp para line-pixel)))) + (defun ekp-dp-cache (string line-pixel) "Compute optimal line breaks for STRING at LINE-PIXEL width. Uses Knuth-Plass dynamic programming with demerits. If `ekp-use-c-module' is non-nil and the C module is available (and `ekp-looseness' is 0), the C module computes the DP." - (let* ((para (ekp--get-para string)) - (cached (ekp--dp-get-cached para line-pixel))) - (cond - (cached cached) - ((ekp--c-available-p) - (ekp--dp-cache-via-c para line-pixel)) - (t (ekp--dp-cache-elisp para line-pixel))))) + (ekp--dp-cache-para (ekp--get-para string) line-pixel)) (defun ekp--lines-data-from-breaks (para line-pixel breaks) "Compute (RESTS . GAPS) lists for BREAKS of PARA at LINE-PIXEL. @@ -1912,18 +1922,15 @@ deficit) spread across GAPS-LIST using PARA's stored glue params." para glues-types distribution stretch-p))) (append '(0) glue-pixels '(0))))) -(defun ekp-line-glues (string line-pixel) - "Compute glue pixels for each line after breaking STRING at LINE-PIXEL. -Returns vector of vectors, each inner vector is glue pixels for one line. -Each line's glues: [0 glue1 glue2 ... trailing-space]." - (let* ((para (ekp--get-para string)) - (boxes-num (length (ekp-para-boxes para))) +(defun ekp--line-glues-from-data (para line-pixel dp) + "Compute glue vectors from prepared PARA at LINE-PIXEL using DP. +Each line's glues are [0 glue1 glue2 ... trailing-space]." + (let* ((boxes-num (length (ekp-para-boxes para))) (glues-types (ekp-para-glues-types para)) (alignment (or (plist-get (ekp-para-glue-params para) :alignment) 'justify)) (ragged (not (eq alignment 'justify))) (hyphen-positions (ekp-para-hyphen-positions para)) - (dp (ekp-dp-data string line-pixel)) (breaks (plist-get dp :breaks)) (lines-rests (plist-get dp :rests)) (lines-gaps (plist-get dp :gaps)) @@ -2011,6 +2018,13 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." (setq start end))) line-glues)) +(defun ekp-line-glues (string line-pixel) + "Compute glue pixels for each line after breaking STRING at LINE-PIXEL. +Returns a vector of per-line glue vectors." + (let ((para (ekp--get-para string))) + (ekp--line-glues-from-data + para line-pixel (ekp--dp-cache-para para line-pixel)))) + ;;;; Rendering (defun ekp--box-space-p (box) @@ -2068,6 +2082,144 @@ leftmost scan aligns them unambiguously." (setq i (1+ i)))) offsets)) +(defun ekp--gap-natural-pixels (para offsets) + "Return memoized natural gap widths for PARA at OFFSETS." + (or (ekp-para-gap-naturals-memo para) + (let* ((string (ekp-para-string para)) + (boxes (ekp-para-boxes para)) + (naturals (make-vector (length boxes) 0))) + (cl-loop + for right from 1 below (length boxes) + for left = (1- right) + for start = (cdr (aref offsets left)) + for end = (car (aref offsets right)) + for source = (if (< start end) + (substring string start end) + (car (last (string-glyph-split + (aref boxes left))))) + do (aset naturals right (ekp--measured-width source))) + (setf (ekp-para-gap-naturals-memo para) naturals)))) + +(defun ekp--layout-line-gaps + (para offsets naturals box-start box-end glues) + "Build semantic gaps for one line of PARA. +OFFSETS maps source boxes and NATURALS their measured gap widths. +BOX-START and BOX-END delimit the kept boxes; GLUES contains the +corresponding leading/interior/trailing pixel widths." + (let ((types (ekp-para-glues-types para)) + (glue-index 1) + gaps) + (cl-loop for right from (1+ box-start) below box-end do + (let* ((left (1- right)) + (source-start (cdr (aref offsets left))) + (source-end (car (aref offsets right))) + (natural (aref naturals right)) + (target (aref glues glue-index))) + (when (or (< source-start source-end) + (> target 0)) + (push (ekp-layout-gap--create + :kind (aref types right) + :left-box left + :right-box right + :source-start source-start + :source-end source-end + :natural-pixel natural + :target-pixel target) + gaps)) + (setq glue-index (1+ glue-index)))) + (vconcat (nreverse gaps)))) + +(defun ekp--layout-line-signature (line) + "Return the stable layout signature for LINE." + (list (ekp-layout-line-source-start line) + (ekp-layout-line-source-end line) + (ekp-layout-line-break-kind line) + (append (ekp-layout-line-glues line) nil))) + +(defun ekp--layout-break-kind (string line next-line) + "Classify the visual break from LINE to NEXT-LINE in STRING." + (cond + ((ekp-layout-line-hyphen-p line) 'hyphen) + ((string-blank-p + (substring string + (ekp-layout-line-source-end line) + (ekp-layout-line-source-start next-line))) + 'space) + (t 'cjk))) + +(defun ekp--finalize-layout-breaks (string lines) + "Add source break ranges, kinds, and signatures to LINES for STRING." + (dotimes (i (length lines)) + (let* ((line (aref lines i)) + (next (and (< i (1- (length lines))) (aref lines (1+ i)))) + (start (ekp-layout-line-source-end line)) + (end (if next (ekp-layout-line-source-start next) start))) + (setf (ekp-layout-line-break-source-start line) start + (ekp-layout-line-break-source-end line) end + (ekp-layout-line-break-kind line) + (and next (ekp--layout-break-kind string line next)) + (ekp-layout-line-signature line) + (ekp--layout-line-signature line)))) + lines) + +(defun ekp--make-layout-line + (para offsets naturals index start end line-glues last-line-p) + "Build one semantic layout line from PARA's DP slice START through END. +OFFSETS maps boxes to source positions and NATURALS stores gap widths. +INDEX is the line number, and LINE-GLUES holds its pixel widths. +LAST-LINE-P suppresses a terminal discretionary hyphen." + (let* ((boxes (ekp-para-boxes para)) + (stripped (ekp--strip-line-spaces + (cl-subseq boxes start end) line-glues + (> index 0) t)) + (kept (nth 0 stripped)) + (glues (vconcat (nth 1 stripped))) + (box-start (+ start (nth 2 stripped))) + (box-end (+ box-start (length kept))) + (hyphen-p (and (not last-line-p) + (ekp--hyphenate-p + (ekp-para-hyphen-positions para) (1- end))))) + (when kept + (ekp-layout-line--create + :index index :box-start box-start :box-end box-end + :source-start (car (aref offsets box-start)) + :source-end (cdr (aref offsets (1- box-end))) + :glues glues + :gaps (ekp--layout-line-gaps + para offsets naturals box-start box-end glues) + :leading-pixel (aref glues 0) + :trailing-pixel (aref glues (1- (length glues))) + :hyphen-p hyphen-p)))) + +(defun ekp-layout-plan (string line-pixel) + "Return a semantic KP layout plan for STRING at LINE-PIXEL. +The plan records source offsets, glue targets, breaks, indentation, +and discretionary hyphens without choosing a string or buffer display +representation." + (let* ((para (ekp--get-para string)) + (boxes (ekp-para-boxes para)) + (offsets (or (ekp-para-box-offsets-memo para) + (setf (ekp-para-box-offsets-memo para) + (ekp--box-offsets string (append boxes nil))))) + (naturals (ekp--gap-natural-pixels para offsets)) + (dp (ekp--dp-cache-para para line-pixel)) + (breaks (plist-get dp :breaks)) + (line-glues (ekp--line-glues-from-data para line-pixel dp)) + (start 0) + lines) + (dotimes (i (length breaks)) + (let* ((end (nth i breaks)) + (line (ekp--make-layout-line + para offsets naturals i start end (aref line-glues i) + (= i (1- (length breaks)))))) + (when line (push line lines)) + (setq start end))) + (setq lines (ekp--finalize-layout-breaks + string (vconcat (nreverse lines)))) + (ekp-layout-plan--create + :string string :line-pixel line-pixel + :boxes boxes :offsets offsets :lines lines))) + (defconst ekp--layout-marker-properties '(ekp-glue ekp-soft-break ekp-soft-hyphen ekp-hidden ekp-justified) "Text properties owned by the lossless render/inversion protocol.") @@ -2121,6 +2273,57 @@ The `ekp-soft-hyphen' property marks it as synthesized, so (text-properties-at (1- (length box)) box)))) (apply #'propertize "-" 'ekp-soft-hyphen t props))) +(defun ekp--render-layout-line-string (plan line) + "Render LINE from PLAN using the reversible string marker protocol." + (let* ((string (ekp-layout-plan-string plan)) + (boxes (ekp-layout-plan-boxes plan)) + (offsets (ekp-layout-plan-offsets plan)) + (start (ekp-layout-line-box-start line)) + (end (ekp-layout-line-box-end line)) + (glues (ekp-layout-line-glues line)) + parts) + (cl-loop for box-index from start below end + for glue-index from 0 do + (push (ekp--render-glue + (aref glues glue-index) + (if (= box-index start) "" + (substring string + (cdr (aref offsets (1- box-index))) + (car (aref offsets box-index))))) + parts) + (push (aref boxes box-index) parts)) + (when (ekp-layout-line-hyphen-p line) + (push (ekp--hyphen-for-box (aref boxes (1- end))) parts)) + (push (ekp--render-glue (aref glues (1- (length glues))) "") parts) + (apply #'concat (nreverse parts)))) + +(defun ekp-render-layout-string (plan) + "Render PLAN as the public reversible justified string." + (let* ((string (ekp-layout-plan-string plan)) + (lines (ekp-layout-plan-lines plan))) + (if (= (length lines) 0) + (ekp--hide-string string) + (let ((parts (list (ekp--hide-string + (substring string 0 + (ekp-layout-line-source-start + (aref lines 0))))))) + (dotimes (i (length lines)) + (let ((line (aref lines i))) + (when (> i 0) + (let ((prev (aref lines (1- i)))) + (push (propertize + "\n" 'ekp-soft-break + (substring string + (ekp-layout-line-source-end prev) + (ekp-layout-line-source-start line))) + parts))) + (push (ekp--render-layout-line-string plan line) parts))) + (let ((last (aref lines (1- (length lines))))) + (push (ekp--hide-string + (substring string (ekp-layout-line-source-end last))) + parts)) + (apply #'concat (nreverse parts)))))) + (defun ekp--pixel-justify (string line-pixel) "Justify single-paragraph STRING to LINE-PIXEL, with render caching. The rendered string for a (paragraph, width) pair is deterministic, @@ -2150,85 +2353,7 @@ The output is lossless with respect to STRING: survives as zero-display `ekp-hidden' text, - break hyphens carry `ekp-soft-hyphen'. `ekp-unjustify-region' inverts all four structurally." - (let* ((para (ekp--get-para string)) - (boxes (append (ekp-para-boxes para) nil)) - (offsets (or (ekp-para-box-offsets-memo para) - (setf (ekp-para-box-offsets-memo para) - (ekp--box-offsets string boxes)))) - (breaks (ekp-line-breaks string line-pixel)) - (num (length breaks)) - (lines-glues (ekp-line-glues string line-pixel)) - (hyphen-positions (ekp-para-hyphen-positions para)) - (start 0) - ;; (rendered-text first-box-idx last-box-idx) per visible line - (lines nil)) - (dotimes (i num) - (let* ((end (nth i breaks)) - (line-boxes (cl-subseq boxes start end)) - (glue-pixels (append (aref lines-glues i) nil)) - ;; Strip space boxes: - ;; - First line (i=0): keep leading spaces (indentation) - ;; - Other lines: strip leading spaces (break artifacts) - ;; - All lines: strip trailing spaces - (is-first-line (= i 0)) - (stripped (ekp--strip-line-spaces line-boxes glue-pixels - (not is-first-line) - t)) - (kept (nth 0 stripped)) - (kept-glues (nth 1 stripped)) - (first-idx (+ start (nth 2 stripped))) - ;; Check if last box of this line needs hyphen - (need-hyphen - (and (< i (1- num)) ; not last line - (ekp--hyphenate-p hyphen-positions (1- end))))) - (when kept - (let ((parts nil) (idx first-idx) (glues kept-glues) (n 0)) - (dolist (box kept) - (push (ekp--render-glue - (pop glues) - (if (> idx first-idx) - (substring string - (cdr (aref offsets (1- idx))) - (car (aref offsets idx))) - ;; leading glue of a line is always 0px and - ;; replaces nothing; edge text is handled by - ;; soft breaks / hidden runs below - "")) - parts) - (push box parts) - (setq idx (1+ idx) n (1+ n))) - (when need-hyphen - (push (ekp--hyphen-for-box (car (last kept))) parts)) - ;; trailing filler glue (synthesized, replaces nothing) - (push (ekp--render-glue (car glues) "") parts) - (push (list (apply #'concat (nreverse parts)) - first-idx (+ first-idx n -1)) - lines))) - (setq start end))) - (setq lines (nreverse lines)) - (if (null lines) - ;; Defensive: no visible box at all (blank paragraphs are - ;; filtered before this function). - (ekp--hide-string string) - (let* ((first-line (car lines)) - (last-line (car (last lines))) - (parts (list (ekp--hide-string - (substring string 0 - (car (aref offsets (nth 1 first-line))))))) - (prev nil)) - (dolist (line lines) - (when prev - (push (propertize "\n" 'ekp-soft-break - (substring string - (cdr (aref offsets (nth 2 prev))) - (car (aref offsets (nth 1 line))))) - parts)) - (push (nth 0 line) parts) - (setq prev line)) - (push (ekp--hide-string - (substring string (cdr (aref offsets (nth 2 last-line))))) - parts) - (apply #'concat (nreverse parts)))))) + (ekp-render-layout-string (ekp-layout-plan string line-pixel))) (defun ekp--validate-width (line-pixel) "Signal a user error unless LINE-PIXEL is a positive integer." diff --git a/postmortem/20260728-buffer-module-naming.md b/postmortem/20260728-buffer-module-naming.md new file mode 100644 index 0000000..ac4c898 --- /dev/null +++ b/postmortem/20260728-buffer-module-naming.md @@ -0,0 +1,101 @@ +# Name Editor Integration After Its Buffer Owner + +## Context + +`ekp-region.el` began as an in-place region formatter, but its stable +responsibility grew to the whole buffer lifecycle: whole-buffer commands, +window-width tracking, incremental reflow, save serialization, isearch, +kill/yank filtering, modified-state preservation, mode presets, and teardown. +The old module name described one public operation scope rather than the +state and external protocols the module owns. + +That mismatch also spread into the feature name, customization group, +configuration variables, private implementation prefix, test file, CI, and +documentation. A reader looking for buffer integration had to know the +historical `region` name first. + +## Decision + +Rename the module directly to `ekp-buffer.el` and provide `ekp-buffer`. +Rename the customization group, module-owned configuration surface, private +implementation namespace, test file, and test namespace to `ekp-buffer*`. +Attach the subgroup to the top-level `ekp` customization group. + +Keep public operation names whose final component describes the real +operand or workflow: + +- `ekp-justify-region` and `ekp-unjustify-region` +- `ekp-justify-buffer` and `ekp-unjustify-buffer` +- the no-break/verbatim region commands +- `ekp-auto-justify-mode`, setup commands, refill, and diagnostics + +The unexported protrusion-reserve calculation now uses the conventional +double-hyphen private name `ekp-buffer--protrusion-reserve`. + +## Compatibility Decision + +This is an explicit breaking change in the Unreleased line. Remove the old +file, feature, variables, and internal names instead of keeping aliases or a +loader shim. The migration is recorded in `CHANGELOG.md`: + +```elisp +(require 'ekp-buffer) +``` + +Configurations that set the module-owned `ekp-region-*` variables must use +their corresponding `ekp-buffer-*` names. + +## Alternatives Rejected + +### Keep `ekp-region` + +This preserves source compatibility but keeps the wrong owner in every file +and symbol lookup. The mismatch is concrete, not hypothetical: most of the +module handles buffer-wide state or editor protocols. + +### Rename to `ekp-mode` + +The minor mode is only one entry point. Manual region and whole-buffer +commands install the same integration lifecycle without enabling the mode. + +### Split Region Commands from Buffer Integration + +The commands, reversible markers, serialization, search, copy filtering, and +mode state share one lifecycle. Splitting them would add declarations and +cross-file glue without creating independent owners. + +### Keep a Compatibility Loader or Aliases + +Two feature names and parallel variable surfaces would preserve the naming +debt and create an indefinite compatibility boundary. The requested +breaking migration has one direct replacement and no persisted-data format +change. + +## Consequences + +- File and feature lookup now identify the actual buffer integration owner. +- Public commands remain semantically precise instead of receiving a + redundant `buffer` prefix. +- Customization appears under the main EKP group. +- Test names, CI commands, and documentation use the same vocabulary. +- Existing configurations must update their `require` and any + module-owned settings; layout behavior and saved bytes do not change. + +## Verification + +The pre-change behavior lock passed 130/130 ERT. After the rename, focused +buffer ERT passed 44/44; default and seeded-permuted full ERT passed 130/130; +all 130 selected ERT tests passed one per fresh Emacs process; and C/Elisp +fuzz passed 300/300. + +Warnings-as-errors byte compilation, checkdoc, pinned package-lint, release +invariants, shell syntax, diff checks, and active-surface stale-name scans +passed. A mechanical equivalence check compared the new runtime and test +files with the old files transformed by the approved rename table; the only +additional runtime differences were the EKP customization parent and the +private protrusion helper name. + +## Rollback + +Reverse the direct file/feature/symbol mapping and the documentation/CI +references together. No data migration or C module rebuild is required. diff --git a/postmortem/20260729-active-line-edge-state.md b/postmortem/20260729-active-line-edge-state.md new file mode 100644 index 0000000..4048f40 --- /dev/null +++ b/postmortem/20260729-active-line-edge-state.md @@ -0,0 +1,60 @@ +# Keep Static Edge Policy out of Live Editor State + +## Context + +The source-clean renderer had two interaction regressions after its initial +quality gate. + +First, every reprojection copied the old mark position and restored it with +`set-mark`. Emacs treats mark position and `mark-active` as separate state, +but `set-mark` changes both. A showcase width key therefore turned an old +inactive mark into a highlighted region. + +Second, the live renderer correctly skipped glue justification on the +point-containing line, then reused static paragraph-edge cleanup. The KP +plan excludes leading/trailing whitespace from its content bounds, so that +cleanup assigned `display ""` to a newly typed edge space. The source +character existed immediately, but the user saw it only after typing the +next glyph. + +## Decision + +Editor state is restored by its actual owners: + +- set the existing mark marker's position directly, then restore + `mark-active` independently; +- retain static edge hiding for committed lines; +- never hide source-edge whitespace on the active live line; +- when the first planned line becomes active, clear projection from the + span's source start so previously hidden leading whitespace becomes + natural too. + +No showcase-specific selection workaround and no space self-insert advice +is added. Both symptoms are fixed in `ekp-buffer.el`, where reprojection and +live-line presentation are owned. + +## Why the Initial Tests Missed It + +The live typing test inserted `" x"` as one operation. By assertion time, +the space was interior and no longer eligible for edge cleanup. The GUI +matrix also tested an intentionally active region, but not an existing +inactive mark. + +The replacement gates stop at the temporal boundary: + +- inspect state immediately after one space, before another glyph; +- cover leading/trailing space, tab, CJK, deletion-exposed whitespace, + consecutive spaces, newline, yank, and undo; +- test inactive and active mark states separately; +- record the real showcase width command and command-loop space insertion + in a clean full-screen GUI run. + +## Consequences + +One space now moves point/cursor immediately and carries no EKP replacing +display. Backspace cannot re-hide the exposed whitespace. Width changes +preserve point, mark position, and region activation exactly. + +Committed static paragraphs still hide stripped edge whitespace, so this +correction does not change completed KP layout or the source-clean +representation contract. diff --git a/postmortem/20260729-editing-frontier-not-point.md b/postmortem/20260729-editing-frontier-not-point.md new file mode 100644 index 0000000..91bb5af --- /dev/null +++ b/postmortem/20260729-editing-frontier-not-point.md @@ -0,0 +1,104 @@ +# Let Source Edits Own the Live Editing Frontier + +> Superseded note (2026-07-29): point-only motion remains a zero-work +> invariant. `20260729-stable-live-transaction.md` replaces the frontier's +> natural-suffix and immediate-publication ownership with a saved committed +> projection and a local dirty edit transaction. + +## Context + +`task028` fixed the planning boundary: all projected rows in one hard line +now come from one complete `ekp-layout-plan`. It nevertheless used current +point to choose the natural suffix whenever `post-command-hook` ran. + +That conflated two independent facts: + +- point is transient navigation state; +- the editing frontier records which semantic line is currently unfinished. + +As a result, cursor motion alone removed and reinstalled projection +properties. It did no DP work, but it still changed the visual document. +Worse, a later width or font reflow could read the cursor's temporary +position and silently move the natural suffix boundary. + +## Decision + +The latest real source edit owns the editing frontier. + +1. A live hard line keeps a source marker for the changed region's new end. +2. Before a real edit, the existing prefix line containing the edit is + returned to native display together with its suffix. +3. After the edit, the frontier marker moves to the changed region's new + end and the whole-hard-line plan is recomputed or reused once. +4. The active semantic index is derived from that marker, never from + transient point. +5. Point-only motion within the active hard line does nothing. +6. Width, font, and layout-context changes preserve the marker and map it + into the new plan. +7. Leaving the hard line still completes the old hard line with the + existing static KP path. + +The core DP remains unaware of point, markers, buffers, and editing state. + +## Why This Is Simpler + +`active-index` is derived plan state; the frontier marker is the one stable +source fact. There is no point-motion state machine and no attempt to infer +editing intent from navigation. One owner event—source mutation—moves the +frontier. + +The change deletes the point-boundary publication path instead of adding +another condition to it. + +## Superseded Decision + +This record supersedes only the point-driven boundary statements in +`20260729-whole-hard-line-live-prefix.md`. That record's main decisions +remain valid: + +- one complete hard line is the planning unit; +- all projected prefix lines come from the same core plan; +- the frontier line and suffix stay natural; +- prior breaks may change together after later source edits. + +## Consequences + +- Moving point cannot cause visual reflow or text-property writes. +- Editing an earlier projected line still makes that actual edit line and + its suffix natural before mutation, then republishes from the updated + whole-hard-line plan. +- Resizing can change breaks and glue, but not because point happened to be + elsewhere inside the hard line. +- The frontier remains display-layer state. Core DP, the C ABI, and the + source-clean representation are unchanged. + +## Verification + +The regression gate compares the complete owned-property projection, +frontier marker, active index, signatures, spans, source characters, +modified tick, undo state, plan calls, cache calls, and property writes +before and after backward/forward point motion. It failed before the change +and now passes; reflow and deferred IME completion also preserve the +frontier instead of reading transient point. + +Default, seed-`20260729`, and isolated full ERT pass 182/182; buffer ERT +passes 93/93 and C/Elisp fuzz passes 300/300. Point-motion p99 is +0.033 ms on the C backend with zero plan/cache calls. + +Reviewed dynamic GUI evidence at +`/private/tmp/ekp-frontier-live-v3-66WYRW` contains a 26.6-second screen +recording and 39 manifest lines. Backward/forward commands preserve the +projection hash, plan, generation, cache size, active index, and frontier +through every required checkpoint. Delete, yank, real undo, resize, +restore, and hard-newline transitions also pass; source text remains exact, +overlays and horizontal scroll remain zero, and the final report is PASS. + +Independent architecture review is CLEAR. Independent code review finds +zero task029 blockers; the separate unique-append performance debt is +recorded as `issue018`/`task030`. + +## Rollback + +Revert this editing-frontier change as one unit if a public editing or GUI +gate fails. Do not restore point-driven projection changes as a fallback; +navigation is not a layout invalidation event. diff --git a/postmortem/20260729-narrow-live-append-replanning.md b/postmortem/20260729-narrow-live-append-replanning.md new file mode 100644 index 0000000..11ef8a5 --- /dev/null +++ b/postmortem/20260729-narrow-live-append-replanning.md @@ -0,0 +1,57 @@ +# Keep Narrow Append Optimization Separate from Frontier Correctness + +## Context + +Task029 removes point from live-layout ownership. Its point-motion workload +is now a zero-work path, but the same benchmark exposed a different +problem: at a synthetic width of 80 pixels, every appended character +creates a new whole-hard-line cache key and therefore invokes the complete +planner again. + +The earlier task028 record reported a 6.399 ms GC-excluded append p99. That +number is not reproducible with the current checked-in benchmark. Repeated +audits instead measured roughly 33–85 ms, while point motion remained near +0.03 ms with zero planner and cache calls. + +## Decision + +Close task029 on its correctness and navigation-performance invariants, and +track unique-state narrow append latency independently as issue018/task030. + +This is not permission to weaken live layout. Task030 must retain: + +- exact whole-hard-line KP semantics; +- exact cache identity; +- immediate source-edit publication; +- source-clean text-property display; +- zero-work point-only motion; +- unchanged core DP/C ABI/schema contracts unless a later architecture + decision explicitly proves a core change is necessary. + +## Why + +The two paths have different owners and different evidence: + +- point motion previously performed unnecessary display work and is fixed + by source-edit-owned frontier state; +- unique append legitimately changes the source and currently pays for a + new whole-hard-line plan. + +Combining them would either delay a verified correctness fix or encourage a +wrong-layer shortcut such as stale reuse, debounce, skipped publication, or +point-dependent planning. + +## Consequences + +- Task029 may complete when its automated, GUI, and independent-review + gates pass. +- Current documentation must report the narrow-width miss rather than the + stale 6.399 ms claim. +- Issue018 remains open until task030 produces equivalent layout results + within an explicitly documented width/latency envelope. + +## Rollback + +There is no runtime change in this decision record. If later optimization +cannot preserve exact layout and editing semantics, abandon that +optimization and leave issue018 open rather than weakening the contract. diff --git a/postmortem/20260729-native-progressive-live-editing.md b/postmortem/20260729-native-progressive-live-editing.md new file mode 100644 index 0000000..c67c80d --- /dev/null +++ b/postmortem/20260729-native-progressive-live-editing.md @@ -0,0 +1,128 @@ +# Let Native Redisplay Own Unfinished Text + +## Context + +The first text-property live engine correctly kept synthesized layout out +of the source character stream, but it still treated an unfinished hard +paragraph as a partial KP document. Stable anchors, lookahead, push/pull, +and convergence limited the amount of recomputation; they did not change +the user-visible ownership error. + +Near the right edge, that engine could choose several visual breaks and a +discretionary hyphen while the user was still typing. Calling one row +“natural” only suppressed its glue adjustment. The surrounding partial KP +plan still controlled wrapping, so editing did not behave like ordinary +Emacs. + +The product requirement is stricter: before the user naturally leaves a +visual row, EKP must have no opinion about its layout. + +## Decision + +Use three states with distinct owners: + +1. The point-containing, underfilled source tail belongs entirely to native + Emacs redisplay. EKP performs no KP planning and publishes no layout + property there. +2. Once native redisplay has moved point to a later visual row, EKP may + align internal gaps on rows already left behind. This projection uses + only `space-width` and `min-width`; it cannot add `line-prefix`, a + replacing break, or a discretionary hyphen. +3. Once a hard paragraph ends or point leaves it, EKP runs one complete KP + pass. Only this state may publish planned visual breaks, indentation, + and discretionary hyphens. + +Before a source edit, the active hard paragraph is restored to native +display. If editing crosses into a previously committed row, that prefix is +therefore invalidated before Emacs changes the source. After the edit and +redisplay, completed native row boundaries are derived with +`vertical-motion`; gap targets are then computed for those fixed source +ranges. The row boundary itself remains Emacs-owned. + +IME composition defers projection. Resize work is generation-checked. +Narrowing may release an inaccessible previous live paragraph but must not +modify it outside the accessible restriction. Foreign replacing display +ownership causes the affected row to remain natural rather than inviting a +second owner. + +## Why This Is Simpler + +The live engine no longer predicts how an incomplete paragraph will flow. +It has no lookahead limit, stable-line signature, anchor-creep rule, +pullback algorithm, or convergence state. Each transition follows a +visible editor event: + +- still on the same native row: do nothing; +- native wrap completed a row: align that row's gaps; +- hard paragraph completed or was left: run full KP. + +The completed-paragraph planner and the live editor now share only +semantic width/glue computations. They do not pretend to share the same +control flow. + +## Alternatives Rejected + +### Keep partial KP and tune the active-line exception + +This retains the wrong owner. Suppressing glue on one chosen row cannot +prevent adjacent planned breaks or hyphens from appearing while the +paragraph is unfinished. + +### Run full KP synchronously after every edit + +Removing an idle timer does not make completed-paragraph optimization an +editing algorithm. It would still move unfinished text and would make +typing latency proportional to paragraph planning. + +### Insert soft newlines or hyphen characters + +That would restore the original source-pollution defect. Direct Elisp +character APIs, save, search, and syntax must continue to observe only +logical text. + +### Use overlays for transient rows + +The project has one text-property projection ownership model, and the user +explicitly excluded overlays. A second transient representation would +complicate invalidation without correcting the row-ownership rule. + +## Consequences + +- Typing before native wrap looks and behaves like auto layout is disabled. +- A committed live row can improve spacing without changing its native + wrap boundary. +- Live editing deliberately has no discretionary hyphenation. Hyphens + appear only in the completed paragraph's display projection and never in + source text. +- Deleting back across a native wrap removes the committed row projection + before the source edit, so Emacs immediately restores its natural flow. +- One authoritative graphical window determines native row boundaries. + Text properties cannot represent simultaneous plans for different window + widths. +- Automatic work remains bounded by the existing hard-paragraph limit; + explicit refill is the opt-in path for an oversized paragraph. + +## Verification + +Behavioral ERT proves the three state transitions through the installed +before/after-change path, including underfilled tails, native wrap, +backward invalidation, hard newline, paragraph exit, IME, narrowing, +resize, foreign ownership, and teardown. + +Static GUI probes prove exact `space-width`/`min-width` projection from +1–64 pixels, display-only completed-paragraph breaks/hyphens/indentation, +unchanged source, and zero overlays across scale, remap, fringe, and width +variants. + +The retained dynamic artifact +`/private/tmp/ekp-native-live-2dYu2U` records native mixed Latin/CJK wrap, +deletion back across the boundary, and hard-paragraph completion. Every +immediate and post-redisplay checkpoint passes, and temporal review finds +no flicker, black segment, or non-Emacs frame. + +## Rollback + +Revert the native progressive live transition as one unit. Do not restore +the partial-KP state machine piecemeal or add a compatibility shim. The +semantic completed-paragraph planner and text-property projection layer +remain independently valid. diff --git a/postmortem/20260729-native-soft-wrap-precondition.md b/postmortem/20260729-native-soft-wrap-precondition.md new file mode 100644 index 0000000..39c2a95 --- /dev/null +++ b/postmortem/20260729-native-soft-wrap-precondition.md @@ -0,0 +1,90 @@ +# Own the Native Soft-Wrap Precondition + +## Context + +The native progressive editor deliberately lets Emacs decide when the +active source line becomes another visual row. That model worked in a +full-width verification window but failed in the user's narrow +side-by-side window: the line horizontally scrolled and displayed a `$` +truncation indicator. + +The source was clean and the KP planner was not involved. Emacs 30.2 +defaults `truncate-partial-width-windows` to `50`; a partial-width window +below that many columns truncates even when `truncate-lines` is nil. +Without a native visual row, the live engine has no completed row to +observe or align. + +## Decision + +`ekp-auto-justify-mode` owns the display precondition its state machine +requires: + +1. On activation, snapshot the values and buffer-local ownership of + `truncate-lines` and `truncate-partial-width-windows`. +2. Make both variables buffer-local and nil before the first reflow. +3. On mode disable or major-mode teardown, restore prior local values or + remove the temporary local bindings so global ownership resumes. +4. Treat activation as a transaction: if width discovery, initial reflow, + hook installation, or integration setup signals, remove partial + lifecycle state, restore the display variables, set the mode back to + disabled, and let the original error propagate. + +This changes only redisplay policy. It creates no source character, +overlay, replacing display break, or independent wrapping algorithm. + +## Alternatives Rejected + +### Treat truncation as a user configuration error + +The failing value is an Emacs default in narrow split windows. A mode whose +algorithm requires native visual rows cannot leave that precondition +implicit and still promise natural editing. + +### Compute hidden visual rows while Emacs truncates + +That would make EKP simulate a display the user cannot see. It would also +leave the reported horizontal-scrolling experience unchanged. + +### Set the variables without restoring them + +That would fix typing by permanently changing unrelated buffer behavior. +Minor-mode ownership must end with the mode. + +### Change the global defaults + +The requirement belongs to one buffer while its auto mode is active. +Changing global behavior would affect unrelated buffers and packages. + +## Consequences + +- Full-width and narrow side-by-side windows use the same native + progressive state transitions. +- Existing `visual-line-mode`/`word-wrap` behavior remains available; + EKP only prevents truncation. +- Disabling auto mode restores the exact prior buffer-local ownership. + When the original state was global, removing the temporary binding lets + the current global value resume. +- Multiple windows showing the same buffer share the buffer-local + soft-wrap policy, matching the existing buffer-wide projection model. + +## Verification + +A public minor-mode lifecycle ERT reproduces the failure red, then proves +both variables are nil and buffer-local while enabled and that their prior +local/global ownership is restored on disable. Separate regressions force +initial reflow failure and switch major modes, proving those teardown paths +restore the same ownership and never leave a half-enabled mode. + +The retained graphical run +`/private/tmp/ekp-soft-wrap-final-pass-PIUigY` types mixed Latin/CJK text +one character at a time in a 44-column left split. The selected window +advances from one to two visual rows with `hscroll=0`, zero live replacing +breaks, zero overlays, exact source text, and no pending transaction. +Temporal review finds only the intended Emacs split, natural continuation, +and a stable final frame. + +## Rollback + +Revert the wrap-state snapshot, activation, and restoration together. +Leaving only the forced values or only the restoration would violate mode +ownership. diff --git a/postmortem/20260729-stable-live-transaction.md b/postmortem/20260729-stable-live-transaction.md new file mode 100644 index 0000000..c1cc5a0 --- /dev/null +++ b/postmortem/20260729-stable-live-transaction.md @@ -0,0 +1,117 @@ +# Separate Stable Editing From Global KP Commitment + +## Context + +`task028` fixed the planning unit by consuming one whole-hard-line KP plan. +`task029` then removed point-only invalidation by preserving a source-edit +frontier. User testing exposed that the combined model still gives the +frontier three jobs: latest edit position, natural-suffix boundary, and +projection reuse boundary. + +That ownership cannot deliver stable editing. Replanning the complete hard +line after every key permits the globally optimal breakpoint set to change +after every key. Line-signature diffing can reduce property writes only after +those new decisions exist; it cannot make the decisions stable. Clearing the +touched line and its entire suffix before every edit also destroys anchors +that were not causally affected. + +The narrow unique-append cost recorded by `issue018` is therefore downstream +of the correctness model, not the first problem to optimize. + +## Decision + +Use two edit-time layers and one completed-layout owner: + +1. A committed projection owns the last published source, core plan, line + signatures, projected spans, and break anchors. +2. A dirty edit transaction snapshots that complete baseline and + naturalizes only the affected local island. +3. The existing core DP remains the sole owner of globally optimal committed + and completed layout. + +Ordinary input inside the same native visual row does not run whole-hard-line +DP and does not rewrite unaffected properties. Existing glue and native soft +wrapping absorb small local changes. If the dirty row no longer fits, native +wrapping moves the necessary local words while following committed break +anchors remain fixed. + +A transaction commits only when: + +- input crosses a native soft-wrap boundary; +- a hard newline/paragraph end is inserted or removed; +- the next real source edit occurs outside the dirty island; +- the user explicitly refills the paragraph; or +- width, font, or layout context changes. + +Point motion is never a commit event. On a soft-wrap commit, the complete +hard-line source may be replanned once and the completed prefix is updated as +one silent publication; the new current row remains natural. On hard +paragraph completion, the existing full-quality KP path remains unchanged. + +If an edit restores the baseline source, the transaction restores the saved +owned projection and committed state directly. Exact restoration is a state +transition, not a hope that recomputation happens to reproduce equivalent +properties. + +## Superseded Decisions + +This record supersedes: + +- `20260729-whole-hard-line-live-prefix.md` where it permits earlier + projected rows to change after every source edit; +- `20260729-editing-frontier-not-point.md` where the edit frontier also owns + the natural suffix and immediate post-edit publication. + +It preserves their valid decisions: + +- the core DP and layout-plan contract stay unchanged; +- committed prefix lines come from one semantic hard-line plan; +- the current unfinished row is natural; +- point-only motion performs zero work; +- hard-paragraph completion receives full-quality KP layout. + +It also restores the useful local-stability principle from the earlier +bounded-flow work without restoring its core continuation API, helper stack, +or per-key DP control flow. + +## Consequences + +- Editing stability and final optimality have different, explicit triggers. +- Unaffected visual rows retain their break anchors during continuous local + editing. +- Reversible source edits restore the exact projection immediately. +- Per-key unique-state planning disappears from ordinary same-row append; the + remaining `task030` surface must be measured after this change. +- The live buffer layer owns more baseline state, but the core algorithm, + source-clean representation, and projection primitives remain unchanged. + +## Verification + +Public hook/command paths must prove ordinary same-row no-plan behavior, +local dirty-island ownership, unaffected-anchor stability, exact +`equal-including-properties` reversal, soft-wrap atomic commit, hard +completion, next-edit-elsewhere commit, zero-work point motion, IME +deferral, width/font/context commit, and fail-closed conflicts. Dynamic GUI +evidence is required because batch tests cannot prove temporal stability or +native word migration. + +## Outcome + +The implementation confirmed the ownership split. Ordinary same-row edits +and point motion now make zero planner calls; the 291-edit narrow benchmark +contains 15 plans, each tied to a real structural crossing. Middle-row edits +retain unrelated span objects, and exact source reversal restores the saved +projection `equal-including-properties` with the original plan, signatures, +and spans. + +The final 48.95-second GUI run records 11 public actions at five phases each. +All 55 checkpoints pass, the run completes in one fullscreen target window, +and temporal review finds no black, blank, split, stale-buffer, or client- +message frame. The surviving structural-commit latency is correctly left to +`task030`; it is no longer evidence against the transaction model. + +## Rollback + +Revert the transaction change as one unit if the public-path or GUI gates +fail. Do not fall back to debounce, idle whole-paragraph formatting, or the +per-key frontier planner; all three retain the same trigger-ownership error. diff --git a/postmortem/20260729-text-property-live-layout.md b/postmortem/20260729-text-property-live-layout.md new file mode 100644 index 0000000..1516eab --- /dev/null +++ b/postmortem/20260729-text-property-live-layout.md @@ -0,0 +1,163 @@ +# Keep KP Decisions Semantic and Buffer Layout Presentational + +> Historical note: the text-property projection decision remains current. +> The partial-KP live state machine described below was replaced by the +> native progressive model in +> `20260729-native-progressive-live-editing.md`. + +## Context + +The original editor integration inserted a reversible rendered string into +the real buffer. Its marker properties made the transform recoverable, and +save/copy/search adapters hid much of the representation, but arbitrary +Elisp character APIs still observed synthesized spaces, newlines, and +discretionary hyphens. + +The required outcome was stronger than reversible formatting: the buffer's +character stream itself had to remain the logical document. The user also +excluded overlays and required existing-character text properties, +specifically absolute-pixel `min-width`, real-space `space-width`, and +`line-prefix`. + +This exposed a second mistake in the old live mode. A function that produces +an optimal completed paragraph is not automatically an editing algorithm. +Running it after an idle delay caused unfinished lines to snap, performed +whole-paragraph work for local edits, and had no explicit overflow, +pullback, composition, or convergence policy. + +The first deletion implementation also recalculated its look-behind from +each backspace. Once two visual lines merged, the next deletion began at a +new line boundary and moved the anchor left again. The source round trip was +correct, but the projection accumulated an extra break because the +recomputation boundary crept into the formerly stable prefix. + +## Decision + +Separate layout decisions from representation. + +`ekp-layout-plan` owns boxes/source offsets, line ranges, exact glue targets, +indentation, chosen breaks, and discretionary-hyphen decisions. The public +string renderer retains its compatible lossless physical returned string. +The buffer renderer consumes the same plan but has no right to insert a +character. + +The buffer projection is: + +- source ASCII space: + `((space-width FACTOR) (min-width ((TARGET-PIXELS))))`; +- zero-source CJK/mixed gap: `min-width` on the preceding complete + grapheme, targeting natural advance plus glue; +- indentation: `line-prefix`; +- explicit break/hyphen: a replacing display string on an existing + complete grapheme that reproduces the grapheme, appends the optional + hyphen, then a visual newline. + +Every public property value is mirrored by an EKP owner property. Cleanup +removes the public value only while it is still identical to the owned +value. Updates use `with-silent-modifications`; owned properties are +nonsticky. No buffer path creates an overlay. + +Live editing uses a separate bounded state machine: + +1. keep one stable source-marker anchor for a continuous editing flow, then + snapshot and clear only its affected projected suffix; +2. keep the point-containing unfinished line naturally spaced; +3. commit the longest fitting prefix on overflow; +4. pull following material back after deletion; +5. stop when source position and a position-independent line signature + match the old plan; +6. defer composition and reject stale generation work; +7. allow a complete quality pass only after leaving the paragraph or an + explicit refill command. + +The active anchor is released when the flow ends: leaving the paragraph, +explicit refill, a hard-boundary reflow, conflict abandonment, or mode +teardown. A new deletion starts one line earlier only when it first begins +exactly at a visual-line boundary; subsequent backspaces reuse the same +anchor. This makes overflow followed by deletion projection-identical. + +Automatic work has a hard single-paragraph limit. An oversized paragraph +stays naturally editable and diagnostic rather than entering an unbounded +DP during mode enable, paste, or typing. `ekp-refill-paragraph` is the +explicit request to pay that cost. + +Text properties are buffer-wide, so the narrowest live window supplies one +authoritative width. Wider windows may show unused space. Simultaneous +different-width KP plans are not claimed. + +## Alternatives Rejected + +### Keep physical buffer text and add more adapters + +No finite adapter list can change what arbitrary `buffer-string`, +`char-after`, syntax, or third-party Elisp reads. This compensates at the +wrong layer. + +### Use overlays + +The user explicitly excluded them. Keeping an overlay backend would also +create two projection ownership models and make lifecycle behavior harder +to reason about. + +### Use `min-width` alone + +It can only add width. Existing ASCII spaces sometimes need shrinking; +`space-width` supplies that shrink and `min-width` supplies the exact pixel +floor after rounding. + +### Use `space-width` alone + +The factor is rounded by redisplay and is not an exact absolute-pixel +guarantee. It also has no effect on tabs. + +### Reuse completed-paragraph formatting after every edit + +Changing its trigger from idle to synchronous would remove the delay but +still perform the wrong unit of work and would justify an unfinished line. +The editing path needs its own active-line and convergence semantics. + +### Promise independent widths in multiple windows + +One set of buffer text properties cannot encode two different break plans. +Pretending otherwise would publish an overflowing plan in at least one +window. + +## Consequences + +- Direct character APIs, search, syntax, save, point, and markers operate on + source characters. +- APIs that preserve or inspect text properties can still observe EKP + projection metadata; copy/kill removes that owned metadata. +- Foreign replacing display ownership makes only the affected hard + paragraph natural and diagnostic. +- Tabs/non-ASCII whitespace remain natural if an exact plan would require + shrinking them. +- Layout property changes do not create layout-only undo or modified-state + changes. +- Ordinary edits perform bounded local flow with no delayed + whole-paragraph snap. +- Very long single paragraphs trade automatic KP quality for bounded input + latency until the user explicitly requests refill. + +## Verification + +The architecture requires three evidence layers: + +- ERT for core plan/string parity, source and lifecycle invariants, exact + property forms, live overflow/pullback/convergence, composition, undo, + ownership, resize, multiwindow policy, and overload behavior; +- clean GUI probes for exact pixel glue, break/hyphen/indent rendering, + point, region, mouse, and source invariants; +- dynamic before/immediate/settled recording for overflow and pullback, + including source/projection hashes, zero overlays, pixel-fit assertions, + and temporal review. + +Final evidence and counts are recorded in the phase change/task documents, +not retroactively copied into this decision record. + +## Rollback + +Revert the text-property buffer renderer and live state machine together. +Do not retain a mixed physical/text-property backend. The semantic plan and +compatible string renderer can remain independently because their boundary +does not depend on buffer projection. diff --git a/postmortem/20260729-whole-hard-line-live-prefix.md b/postmortem/20260729-whole-hard-line-live-prefix.md new file mode 100644 index 0000000..6674c15 --- /dev/null +++ b/postmortem/20260729-whole-hard-line-live-prefix.md @@ -0,0 +1,147 @@ +# Let Live Editing Consume the Whole Hard-Line Plan + +> Superseded note (2026-07-29): the whole-hard-line planning decision +> remains current, but `20260729-editing-frontier-not-point.md` supersedes +> this record's point-driven projection-boundary statements. +> +> Later supersession (2026-07-29): +> `20260729-stable-live-transaction.md` preserves whole-hard-line planning at +> structural commits but supersedes replanning after every source edit. + +## Context + +The native progressive live model fixed the most visible editing problems: +typing before native wrap stayed natural, narrow split windows soft-wrapped, +and live mode no longer published discretionary hyphens or replacing +breaks into the active tail. + +User testing then exposed the next owner mistake. A hard line that naturally +wraps into several visual rows still needs the rows before point to be +decisions from one Knuth-Plass plan. The native-row model aligned rows after +Emacs had already wrapped them, then treated those row boundaries as local +facts. That cannot produce global KP behavior because later source text can +change earlier optimal breaks and glue. + +The failed assumption was subtle: "native redisplay owns the active edit +tail" does not imply "native rows are the durable planning unit." Native +redisplay should still own the point-containing unfinished area, but the +completed prefix should be projected from the semantic KP plan for the +whole current hard line. + +## Decision + +Keep the core algorithm unchanged. `ekp-layout-plan` already computes the +correct plan for complete text. `ekp-buffer` must consume that plan for the +current hard line and choose the editable projection boundary. + +The live pipeline is: + +1. Read the complete source text of the current hard line. +2. Call the existing `ekp-layout-plan` with the authoritative width and + current layout context. +3. Find the semantic plan line that contains point. +4. Project only complete plan lines before that line. +5. Leave the point-containing plan line and every later source character + completely natural. +6. Compare old and new semantic line signatures so unchanged prefixes stay + installed and changed suffixes are removed/reinstalled atomically. + +This state belongs entirely to `ekp-buffer`: hard-line bounds, source and +context signatures, the latest plan, line signatures, installed prefix +range, generation, and a small buffer-local history cache for undo/redo and +width/text round trips. + +## Boundaries + +- Do not change core DP semantics. +- Do not add continuation DP, live DP, or final-line special cases. +- Do not change the C ABI, DP schema, or core/Elisp algorithm contract. +- Do not copy the DP into `ekp-buffer`. +- Do not add buffer, point, window, marker, or redisplay state to the core + planner. +- Do not use overlays. +- Do not insert source spaces, source newlines, or source hyphen + characters. + +The completed-paragraph planner remains the owner of optimal breaks and +glue. The buffer layer owns edit-time projection, invalidation, and +redisplay safety. + +## Superseded Model + +`20260729-native-progressive-live-editing.md` remains useful history for +two decisions that still stand: + +- the point-containing active area must remain natural; +- full static KP projection is allowed after hard-paragraph completion. + +It is superseded for the planning unit. Completed native visual rows are +not stable committed rows. They are only a symptom that there is now a +semantic prefix before point that may be safe to project. + +## Consequences + +- Earlier displayed rows in the same hard line can change together when + later input changes the optimal KP plan. +- Moving point into an earlier semantic line immediately makes that line + and the following source natural again. +- Point movement without text change still matters because it changes the + projection boundary. +- Plan cache hits are possible when undo/redo, deletion, width changes, or + point movement revisit a known hard-line text/context signature. +- Projection failure must not advance a tail marker or leave half of a KP + prefix installed; the affected hard line returns to native display. +- First-line underfilled editing remains indistinguishable from ordinary + Emacs because there is no complete semantic prefix before point. + +## Verification + +The implementation proves the behavior through public editing paths, not +by stubbing row lists: + +- `self-insert-command`, yank, delete, real undo/redo, point movement, + hard newline, resize, and major-mode change; +- point in last, middle, and first semantic plan line; +- early plan breaks changing after later edits, proving rows are not frozen; +- plan-cache hits and zero property writes when the semantic plan is + unchanged; +- clean GUI dynamic evidence with exact source text, zero overlays, + `hscroll=0`, natural point line, aligned prefix lines, and no delayed + idle snap. + +## Implementation Outcome + +`task028` implemented this decision entirely in `ekp-buffer`. The core DP, +C ABI, DP schema, and `ekp-layout-plan` contract were not changed. The live +state now uses whole-hard-line plans, a 16-entry buffer-local history LRU, +semantic line signatures, common-prefix differential publication, and +transactional fail-closed cleanup. + +Default and seed-`20260729` ERT pass 181/181, all 181 tests pass in isolated +Emacs processes, and C/Elisp fuzz passes 300/300. The reviewed main dynamic +GUI run `/private/tmp/ekp-semantic-live-v4-vFZTkr` and 44-column split run +`/private/tmp/ekp-semantic-split-v3-uPwuOi` both return PASS with exact +source text, zero overlays, `hscroll=0`, a natural point line, and no +temporal snap or stale projection. + +The original C-backend run recorded append p99 6.399 ms with GC excluded. +A later task029 audit could not reproduce that number with the current +checked-in benchmark: the synthetic 80-pixel workload instead records one +plan per unique append and roughly 33–85 ms p99. That separate performance +debt is now `issue018`/`task030`; see +`20260729-narrow-live-append-replanning.md`. The implementation does not +hide it with stale reuse, debounce, skipped publication, global GC changes, +or a timer workaround. + +Final independent code review returns APPROVE and independent architecture +review returns CLEAR, including the theme-disable and frame-font +invalidation delta. + +Developer verification does not close `issue016`; the user must still +confirm the editing experience personally. + +## Rollback + +Revert the semantic-prefix live implementation as one unit if it fails its +public-path or GUI gates. Do not restore native-row freezing as the final +model; it is known to be the wrong planning boundary for global KP layout. diff --git a/postmortem/20260730-c-resize-hot-path-ownership.md b/postmortem/20260730-c-resize-hot-path-ownership.md new file mode 100644 index 0000000..42e3b98 --- /dev/null +++ b/postmortem/20260730-c-resize-hot-path-ownership.md @@ -0,0 +1,53 @@ +# C Resize Hot-Path Ownership + +## Context + +The reported 60–70 ms resize number was initially described as C dynamic +module performance. The measured operation actually crossed core planning, +the Emacs module boundary, projection construction, text-property +publication, and live-prefix replacement. + +## What the profile showed + +The portable C call averaged roughly 1.8 ms in the frozen four-round +baseline. Repeated paragraph lookup and plan construction dominated the core +path, while redundant gap measurement and property publication dominated +the buffer projection path. Resize also published the active paragraph +statically before replacing it with the live prefix. + +An apparent remaining 60–70 ms GUI spike was a separate phenomenon: +`set-window-margins` allocated in the Emacs window system and a later EKP +callback paid the resulting GC pause. The EKP mutator stayed below the +50 ms contract when total, GC, and mutator time were recorded separately. + +## Decision + +Keep the C ABI and Knuth-Plass algorithm unchanged. Move prepared paragraph, +DP, and natural gap geometry through the existing core-to-buffer path, +remove only true no-op projection records, consolidate owned property +publication, and make resize publish the active paragraph only through its +live owner. + +## Rejected alternatives + +- Micro-optimizing C DP: it was not the dominant measured layer. +- Increasing resize debounce or skipping widths: this hides work and makes + projection stale. +- Reusing approximate plans across widths: this breaks exact layout parity. +- Raising the global GC threshold: this changes process-wide behavior and + misattributes window-system allocation to EKP. + +## Consequences + +The four-round gate reduces core p95 from 42.006 ms to 27.687 ms and complete +resize p95 from 46.611 ms to 27.487 ms, with 34.09% and 41.03% improvements. +Frozen C, candidate C, and Elisp layout hashes remain identical. External GC +can still increase wall-clock totals, but it is explicitly observable and +is not concealed by runtime policy changes. + +## Verification and rollback + +The evaluator, normal/permuted/isolated ERT suites, fuzzing, warning-as-error +builds, static and release gates, and reviewed temporal GUI evidence pass. +Rollback is a direct revert of the prepared-data and publication changes; +no compatibility layer or data migration is required. diff --git a/readme.md b/readme.md index 19a99a7..9930263 100644 --- a/readme.md +++ b/readme.md @@ -15,9 +15,14 @@ typesetting, entirely inside Emacs. one); dedicated inter-CJK and CJK↔Latin spacing. - **Hyphenation** — Frank Liang's algorithm (the TeX algorithm) with 49 checksum-pinned Hunspell pattern dictionaries bundled. -- **Pixel-accurate justification** — every justified line renders at - exactly the requested pixel width, using `display (space :width ...)` - properties; works with variable-width fonts. +- **Pixel-accurate justification** — one semantic layout plan drives both + renderers. The string API uses pixel spaces; buffer layout combines + `space-width` with absolute-pixel `min-width`, so it works with + variable-width fonts without inserting layout characters. +- **Clean editable buffers** — buffer commands create no overlays and add + no glue spaces, soft newlines, or discretionary hyphens to the character + stream. `buffer-string`, `char-after`, search, syntax, save, and ordinary + Elisp text consumers see the source characters. - **Text properties preserved** — faces, colors and other properties survive justification; inserted hyphens inherit the face of the word they break. @@ -40,7 +45,7 @@ Clone the repository and add it to your `load-path` (the ```elisp (add-to-list 'load-path "/path/to/emacs-kp") (require 'ekp) -(require 'ekp-region) ; buffer/region commands +(require 'ekp-buffer) ; buffer/region commands ``` Byte-compiling is strongly recommended — the Elisp engine is about @@ -82,48 +87,77 @@ rebuild. ## Interactive Use (buffer & region) -`ekp-region.el` turns the string API into buffer-level commands: +`ekp-buffer.el` turns the string API into buffer-level commands: ```elisp -(require 'ekp-region) +(require 'ekp-buffer) ``` - `M-x ekp-justify-region` — justify the region to the window text width (with a numeric prefix argument, to that many pixels). With no active region, it justifies the paragraph at point. - `M-x ekp-justify-buffer` — justify the whole buffer. -- `M-x ekp-unjustify-region` / `ekp-unjustify-buffer` — restore the - original text **exactly**, including collapsed whitespace runs. - Justification is lossless: every synthesized space, soft line break, - and soft hyphen carries the original text it replaced, so restoring - is a structural transform that also works after you edited the - justified text. -- `M-x ekp-auto-justify-mode` — keep the whole buffer justified to the - window width. Re-flows (debounced by - `ekp-auto-justify-resize-delay`) when the window width changes, and - after edits re-justifies only the touched paragraphs - (`ekp-auto-justify-edit-delay`), so unchanged paragraphs hit the - paragraph cache. Turning the mode off restores the buffer exactly. +- `M-x ekp-unjustify-region` / `ekp-unjustify-buffer` — remove EKP's + display projection. The source text does not need restoration because + buffer layout never replaced it. +- `M-x ekp-auto-justify-mode` — keep completed hard paragraphs justified + while ordinary typing remains stable. The active hard line has a committed + projection plus one local edit transaction. Typing within the same native + visual row does no whole-line planning and leaves unaffected projected rows + untouched. Editing a projected middle row naturalizes only that row's dirty + island, so later break anchors stay in place and native wrapping handles + local word migration. When input naturally crosses into the next visual + row, EKP runs or reuses one complete `ekp-layout-plan` and atomically + publishes all completed rows; the new row remains natural. Deleting and + reinserting the same source restores the saved projection exactly, + including text properties. + Point motion never plans or writes layout properties, even when point + leaves the paragraph. Global commits happen only at a visual-row crossing, + hard newline/paragraph completion, the next real edit elsewhere, explicit + refill, or a width/font/layout-context change. There is no edit-idle + whole-paragraph snap. Window resize re-flow is debounced by + `ekp-auto-justify-resize-delay`. + The mode temporarily disables both explicit line truncation and Emacs's + narrow partial-window truncation, so a side-by-side editing window still + soft-wraps normally. Disabling the mode restores the prior buffer-local + or global ownership of both settings. + A leading or trailing space/tab on the active line is visible in the + same input turn; deleting a following glyph does not hide that source + whitespace. Reprojection also preserves an inactive mark as inactive, so + width changes do not create an accidental selection. While active, the standard **EKP** menu exposes formatting, protection, and window-fit diagnostic commands; `C-h m` describes the same workflow. -The buffer is treated as a live document, not just a canvas: +The projection uses text properties on existing source graphemes only: -- **Saving** writes the *logical* text — soft line breaks, glue - spaces and break hyphens never reach disk; the on-screen buffer - stays justified even if writing fails or is interrupted. - This guarantee applies to whole-buffer saves; an explicit region-only - `write-region` writes the selected physical buffer representation. -- **Searching** (isearch) sees the logical text, so CJK phrases and - hyphenated words are found across the layout. -- **Copying** puts the logical text on the kill ring, so pasted text - carries words, not pixel spacing. Existing mode/user substring filters - remain active and are restored when the final layout span is removed. -- Merely enabling the mode never marks the buffer modified (no stray - lock files or auto-saves), and `undo` is not fought by the re-flow - timer. +- Existing ASCII spaces receive + `((space-width FACTOR) (min-width ((TARGET-PIXELS))))`. +- A CJK or mixed gap with no source space adds `min-width` to the preceding + complete grapheme; the target is its natural advance plus the glue. +- `line-prefix` supplies indentation. A break or discretionary hyphen is + a replacing display string on an existing complete grapheme. +- EKP never creates an overlay. It also never steals a foreign replacing + `display`, `line-prefix`, `wrap-prefix`, `composition`, or `invisible` + owner; that hard paragraph stays natural and `M-x ekp-diagnose` reports + the conflict. -`ekp-region-margin-pixel` (default 2) is subtracted from the window +Consequently: + +- **Elisp APIs and saving** see the original character sequence. Visual + spaces, newlines, and hyphens cannot reach disk or syntax/search logic. + `buffer-substring` can still carry the EKP display properties because it + preserves text properties; `buffer-substring-no-properties` is the plain + source string. +- **Searching** (including isearch) operates directly on source text, so a + word remains one word across a visual discretionary break. +- **Copying and killing** strip EKP-owned projection properties while + composing with any existing substring filter. Pasted text contains only + the logical content and its non-EKP properties. +- Projection updates run inside `with-silent-modifications`: enabling, + editing, resizing, and disabling layout do not create layout-only undo + entries, modified-state changes, or character-modified ticks. + +`ekp-buffer-margin-pixel` (default 2) is subtracted from the window width as a rounding safety margin. Large buffers (over `ekp-auto-justify-lazy-threshold` characters, @@ -132,6 +166,13 @@ synchronously and the rest follows in idle background chunks, with a per-tick time budget (`ekp-auto-justify-tick-budget`) and priority for whatever you scroll to. +Automatic planning is also bounded per hard paragraph. +`ekp-auto-justify-paragraph-limit` defaults to 2 048 characters. A longer +single paragraph stays naturally wrapped and fully editable instead of +blocking input in an unbounded Knuth-Plass pass; `M-x ekp-diagnose` reports +the reason. Run `M-x ekp-refill-paragraph` when you explicitly want the +unbounded full-quality pass for that paragraph. + Mode presets for verbatim protection — one call each: ```elisp @@ -146,8 +187,8 @@ in Org and Markdown buffers when you have not configured your own. - Block level: paragraphs carrying the `ekp-verbatim` text property (`M-x ekp-verbatim-region`), wearing a face listed in - `ekp-region-skip-faces` (e.g. `org-block`, `markdown-code-face`), or - matched by the buffer-local function `ekp-region-skip-predicate` + `ekp-buffer-skip-faces` (e.g. `org-block`, `markdown-code-face`), or + matched by the buffer-local function `ekp-buffer-skip-predicate` pass through completely untouched. - Inline level: spans carrying `ekp-no-break` (`M-x ekp-no-break-region`) become rigid atoms — never broken, @@ -158,7 +199,7 @@ Manual properties are deliberately **current-buffer-session only**: plain-text saving and reopening do not persist them. Use `M-x ekp-allow-break-region` / `ekp-clear-verbatim-region` to remove them. For protection derived from persistent document syntax, use mode faces or -the buffer-local `ekp-region-skip-predicate` (the Org/Markdown presets do +the buffer-local `ekp-buffer-skip-predicate` (the Org/Markdown presets do this automatically). ## Typography @@ -292,6 +333,16 @@ the point.) ## Known Limitations +- Text properties are buffer-wide, so one buffer cannot carry independent + plans for windows of different widths. EKP uses the narrowest live + window as the authoritative width; wider windows can show unused space + but never overflow. +- EKP-owned layout properties are visible to APIs that explicitly inspect + text properties. The character stream is clean; copy/kill removes the + owned projection metadata. +- Tabs and non-ASCII whitespace cannot be shrunk with `space-width`. If an + exact plan would require that operation, EKP leaves the affected hard + paragraph natural and reports the conflict. - Measurement follows the current buffer's face remappings (`text-scale-mode`, themes, `ekp-org-setup`-style tweaks) and reserves the truncation-indicator column in windows without @@ -336,7 +387,7 @@ emacs -Q -L /path/to/emacs-kp -L /path/to/emacs-kp/tests \ The matrix prints every row and exits with status 1 if any fit check fails, so the same command can gate local release automation. The verifier is a -developer tool under `tests/`; it is not loaded by `(require 'ekp-region)`. +developer tool under `tests/`; it is not loaded by `(require 'ekp-buffer)`. ## Credits diff --git a/readme_zh.md b/readme_zh.md index d3d9d6d..df658d7 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -14,8 +14,12 @@ Emacs-kp 在 Emacs 内部完整实现了 Knuth-Plass 最优断行算法,支持 独立可调。 - **连字符断词** — Frank Liang 算法(TeX 同款),内置 49 份带固定来源 与 SHA-256 的 Hunspell pattern 词典。 -- **像素级两端对齐** — 每一行渲染宽度精确等于目标像素宽度(通过 - `display (space :width ...)` 属性实现),支持变宽字体。 +- **像素级两端对齐** — 一份语义 layout plan 同时驱动字符串与 buffer + 渲染器。字符串 API 使用像素空格;buffer 组合 `space-width` 与绝对 + 像素 `min-width`,不插入排版字符也能支持变宽字体。 +- **干净且可编辑的 buffer** — buffer 命令不创建 overlay,也不会向字符流 + 加入 glue 空格、软换行或断词连字符。`buffer-string`、`char-after`、 + 搜索、语法、保存及普通 Elisp 文本 API 看到的都是源字符。 - **文本属性保留** — face、颜色等属性完整保留;断词插入的连字符继承所 在单词的样式。 - **困难输入不丢内容** — 超长不可断 token(URL、窄栏长词)退化为紧急 @@ -35,7 +39,7 @@ Emacs-kp 在 Emacs 内部完整实现了 Knuth-Plass 最优断行算法,支持 ```elisp (add-to-list 'load-path "/path/to/emacs-kp") (require 'ekp) -(require 'ekp-region) ; buffer/region 命令 +(require 'ekp-buffer) ; buffer/region 命令 ``` 强烈建议字节编译——编译后 Elisp 引擎约快 10 倍。 @@ -72,47 +76,75 @@ Elisp。已启用模块若 signal,则作为后端契约错误直接呈现。若 ## 交互使用(buffer 与 region) -`ekp-region.el` 把字符串 API 变成 buffer 级命令: +`ekp-buffer.el` 把字符串 API 变成 buffer 级命令: ```elisp -(require 'ekp-region) +(require 'ekp-buffer) ``` - `M-x ekp-justify-region` — 把选区排版到窗口文本宽度(数字前缀参数 可指定像素宽)。没有激活选区时,排版光标所在段落。 - `M-x ekp-justify-buffer` — 排版整个 buffer。 -- `M-x ekp-unjustify-region` / `ekp-unjustify-buffer` — **精确**还原 - 原文,包括被折叠的连续空格。排版是无损的:每个合成空隙、软换行、 - 软连字符都携带它所替换的原文,还原是纯结构变换,即使排版后又编辑 - 过也能正确还原。 -- `M-x ekp-auto-justify-mode` — 让整个 buffer 保持按窗口宽度排版。 - 窗口宽度变化时自动重排(防抖延迟 `ekp-auto-justify-resize-delay`); - 编辑后只重排被改动的段落(空闲延迟 `ekp-auto-justify-edit-delay`), - 未变段落直接命中段落缓存。关闭 mode 时 buffer 精确恢复原状。 +- `M-x ekp-unjustify-region` / `ekp-unjustify-buffer` — 移除 EKP 的 + 显示投影。buffer 排版从未替换源文本,因此不需要“还原字符”。 +- `M-x ekp-auto-justify-mode` — 让已完成硬段落保持按窗口宽度排版, + 同时让普通输入保持稳定。活动硬行由“已提交投影 + 一个局部编辑事务” + 组成。同一原生视觉行内输入时,不会规划整条硬行,也不会改写无关的已 + 投影行。编辑中间已投影行时,只让该行的真实脏岛恢复自然;后续断行锚点 + 保持不动,局部单词迁移交给 Emacs 原生软折行。输入自然跨入下一视觉行 + 时,EKP 才调用或复用一次完整 `ekp-layout-plan`,并原子发布所有已完成 + 行;新行继续自然显示。删除后插回完全相同的源文本会立即逐属性恢复保存 + 的完整投影。 + 仅移动 point 永远不会规划或写布局属性,即使 point 离开段落也是如此。 + 全局提交只发生在视觉行跨越、硬换行/段落完成、下一次真实编辑发生在 + 别处、显式 refill,或宽度/字体/布局上下文变化时。不存在编辑后空闲触发 + 的整段跳变。窗口尺寸变化仍通过 + `ekp-auto-justify-resize-delay` 防抖。 + mode 启用期间会临时关闭显式行截断以及 Emacs 的窄分栏窗口截断, + 因此左右分栏再窄也会正常软折行;关闭 mode 时会精确恢复这两个变量 + 原来的 buffer-local 或全局所有权。 + 活动行首尾输入一个空格或 tab 会在同一次输入中立即可见;删除其后的 + 字符也不会把该源空白隐藏。重投影还会分别保持 mark 位置与 + `mark-active`,因此改变宽度不会把旧 mark 变成意外选区。 mode 激活时,标准 **EKP** 菜单提供排版、保护与窗口适配诊断命令; `C-h m` 也会说明同一套流程。 -buffer 被当作活的文档,而不只是画布: +投影只使用现有源字素上的文本属性: -- **保存**时写入的是**逻辑文本**——软换行、glue 空格、断词连字符属于 - 排版而非内容,不会落盘;即使写盘失败或中断,屏幕 buffer 也保持排版态。 - 该保证适用于整 buffer 保存;显式只写局部的 `write-region` 会写入所选 - 区域当前的物理 buffer 表示。 -- **搜索**(isearch)看到的是逻辑文本,中文短语与被断词的英文单词 - 都能跨排版找到。 -- **复制**放进 kill ring 的是逻辑文本,粘贴出去的是文字而非像素间距; - 已有 mode/user substring filter 会继续生效,最后一个排版区间移除后 - 精确恢复。 -- 仅仅开启 mode 不会把 buffer 标记为已修改(不产生锁文件或 auto-save), - 重排定时器也不再与 `undo` 打架。 +- 源 ASCII 空格使用 + `((space-width FACTOR) (min-width ((TARGET-PIXELS))))`。 +- 没有源空格的 CJK/混排间距,把 `min-width` 加到前一个完整字素上, + 目标值为“字素自然 advance + glue”。 +- 缩进使用 `line-prefix`;视觉断行与断词连字符使用挂在现有完整字素上的 + replacing display string。 +- EKP 绝不创建 overlay,也不抢占外部的 replacing `display`、 + `line-prefix`、`wrap-prefix`、`composition` 或 `invisible`。有冲突 + 的硬段落保持自然显示,`M-x ekp-diagnose` 会报告原因。 -`ekp-region-margin-pixel`(默认 2)是从窗口宽度中扣除的取整安全边距。 +因此: + +- **Elisp API 与保存**直接看到原始字符序列,视觉空格、换行和连字符不可能 + 进入磁盘、语法或搜索逻辑。`buffer-substring` 会保留文本属性,所以可能 + 携带 EKP 的显示属性;`buffer-substring-no-properties` 是纯源字符串。 +- **搜索**(包括 isearch)直接搜索源文本,一个拉丁单词不会因为视觉断词而 + 变成两个词。 +- **复制/剪切**会在组合已有 substring filter 的同时移除 EKP 自有投影 + 属性;粘贴内容只包含逻辑文本及非 EKP 属性。 +- 投影更新包在 `with-silent-modifications` 中:启用、编辑、缩放及关闭 + 排版不会制造仅由布局引起的 undo 条目、modified 状态或字符修改 tick。 + +`ekp-buffer-margin-pixel`(默认 2)是从窗口宽度中扣除的取整安全边距。 大 buffer(超过 `ekp-auto-justify-lazy-threshold` 字符,默认 2 万) 自动改为可视优先重排:屏幕内的部分同步完成,其余在空闲时后台分块 补齐,每个时间片有时间预算(`ekp-auto-justify-tick-budget`),并优先 处理你滚动到的区域。 +自动规划还按硬段落设有上限。`ekp-auto-justify-paragraph-limit` 默认 +2 048 字符。更长的单个硬段落保持自然折行与完整可编辑性,避免一次无界 +Knuth-Plass 计算阻塞输入;`M-x ekp-diagnose` 会报告这一原因。确实需要 +对该段执行无上限完整质量排版时,显式运行 `M-x ekp-refill-paragraph`。 + 各 mode 的 verbatim 保护预设——各一行: ```elisp @@ -126,9 +158,9 @@ buffer 被当作活的文档,而不只是画布: ### 保护代码块与 verbatim 文本 - 段落级:携带 `ekp-verbatim` 文本属性(`M-x ekp-verbatim-region`)、 - face 在 `ekp-region-skip-faces` 列表中(如 `org-block`、 + face 在 `ekp-buffer-skip-faces` 列表中(如 `org-block`、 `markdown-code-face`)、或被 buffer-local 的 - `ekp-region-skip-predicate` 判定的段落**原样跳过**,一个字节都不动。 + `ekp-buffer-skip-predicate` 判定的段落**原样跳过**,一个字节都不动。 - 行内级:带 `ekp-no-break` 属性的区间(`M-x ekp-no-break-region`) 成为刚性原子——不断行、不断词、空格保持字面宽度——适合行内代码、 产品名、数字加单位。 @@ -136,7 +168,7 @@ buffer 被当作活的文档,而不只是画布: 手动属性明确只在**当前 buffer 会话**有效:普通文本保存与重新打开不会 恢复它们。使用 `M-x ekp-allow-break-region` / `ekp-clear-verbatim-region` 清除。需要从持久文档语法派生保护时,使用 -mode face 或 buffer-local 的 `ekp-region-skip-predicate`(Org/Markdown +mode face 或 buffer-local 的 `ekp-buffer-skip-predicate`(Org/Markdown 预设会自动这样做)。 ## 排版特性 @@ -250,6 +282,13 @@ Silicon 测得;方法见 DEVELOPER_ZH.md: ## 已知限制 +- 文本属性属于 buffer,不能为同一 buffer 在不同宽度窗口保存两套 plan。 + EKP 以显示该 buffer 的最窄活动窗口为权威宽度;较宽窗口可能右侧留白, + 但不会溢出。 +- 显式检查文本属性的 Elisp API 能看到 EKP 自有布局属性;干净保证针对 + 字符流。复制/剪切会移除这些投影元数据。 +- `space-width` 不能缩窄 tab 或非 ASCII 空白。若精确 plan 要求这种操作, + EKP 会让受影响硬段落保持自然显示并报告冲突。 - 测量会跟随当前 buffer 的 face 重映射(`text-scale-mode`、主题等), 并在无 fringe 的窗口里为截断指示符预留一列,排版行贴合真实显示。 若在特殊配置下仍出现截断或偏短,在该 buffer 里执行 @@ -287,7 +326,7 @@ emacs -Q -L /path/to/emacs-kp -L /path/to/emacs-kp/tests \ 矩阵会打印全部行;任一贴合检查失败时以状态码 1 退出,因此同一命令可 作为本地发布门禁。验证器是 `tests/` 下的开发工具, -`(require 'ekp-region)` 不会加载它。 +`(require 'ekp-buffer)` 不会加载它。 ## 致谢 diff --git a/tests/ekp-buffer-live-bench.el b/tests/ekp-buffer-live-bench.el new file mode 100644 index 0000000..aec5d55 --- /dev/null +++ b/tests/ekp-buffer-live-bench.el @@ -0,0 +1,239 @@ +;;; ekp-buffer-live-bench.el --- Live edit benchmarks for EKP -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Reproducible public-path latency measurements for `ekp-auto-justify-mode'. +;; +;; emacs -Q --batch -L . -L tests -l tests/ekp-buffer-live-bench.el +;; +;; To benchmark the C backend, load it before this file. Batch glyph +;; measurements are internally comparable but are not a replacement for +;; the graphical dynamic verification. Bind `gc-cons-threshold' to +;; `most-positive-fixnum' to separate mutator latency from GC pauses. + +;;; Code: + +(require 'cl-lib) +(require 'ekp-buffer) + +(defconst ekp-buffer-live-bench--width 80) + +(defconst ekp-buffer-live-bench--base + (concat + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda " + "中文混排让前面的完整语义行共同调整 while the active line stays natural")) + +(defvar ekp-buffer-live-bench--expected nil) +(defvar ekp-buffer-live-bench--times nil) +(defvar ekp-buffer-live-bench--gc-events 0) +(defvar ekp-buffer-live-bench--gc-seconds 0.0) + +(defun ekp-buffer-live-bench--percentile (values percentile) + "Return PERCENTILE from numeric VALUES." + (let* ((sorted (sort (copy-sequence values) #'<)) + (index (1- (ceiling (* percentile (length sorted)))))) + (nth (max 0 (min index (1- (length sorted)))) sorted))) + +(defun ekp-buffer-live-bench--statistics () + "Return latency statistics for the current scenario." + (let ((times (nreverse ekp-buffer-live-bench--times))) + (list :edits (length times) + :median (ekp-buffer-live-bench--percentile times 0.50) + :p95 (ekp-buffer-live-bench--percentile times 0.95) + :p99 (ekp-buffer-live-bench--percentile times 0.99) + :max (apply #'max times) + :gc-events ekp-buffer-live-bench--gc-events + :gc-ms (* 1000 ekp-buffer-live-bench--gc-seconds)))) + +(defun ekp-buffer-live-bench--record-command (command expected) + "Run public COMMAND, verify EXPECTED source, and record its latency." + (let ((started (float-time)) + (gcs-before gcs-done) + (gc-before gc-elapsed)) + (funcall command) + (run-hooks 'post-command-hook) + (push (* 1000 (- (float-time) started)) + ekp-buffer-live-bench--times) + (cl-incf ekp-buffer-live-bench--gc-events + (- gcs-done gcs-before)) + (cl-incf ekp-buffer-live-bench--gc-seconds + (- gc-elapsed gc-before))) + (setq ekp-buffer-live-bench--expected expected) + (unless (equal (substring-no-properties (buffer-string)) expected) + (error "Live benchmark source mismatch")) + (when (overlays-in (point-min) (point-max)) + (error "Live benchmark created an overlay"))) + +(defun ekp-buffer-live-bench--insert (character) + "Insert CHARACTER through `self-insert-command'." + (ekp-buffer-live-bench--record-command + (lambda () + (let ((last-command-event character)) + (call-interactively #'self-insert-command))) + (concat ekp-buffer-live-bench--expected (string character)))) + +(defun ekp-buffer-live-bench--delete-backward () + "Delete one character through `delete-backward-char'." + (ekp-buffer-live-bench--record-command + (lambda () (call-interactively #'delete-backward-char)) + (substring ekp-buffer-live-bench--expected 0 -1))) + +(defun ekp-buffer-live-bench--append-workload () + "Type enough mixed prose to cross several semantic boundaries." + (dotimes (_ 3) + (mapc #'ekp-buffer-live-bench--insert + (string-to-list + (concat + " extraordinary editing continues smoothly 中文拉丁混排" + " and all earlier complete lines may move together"))))) + +(defun ekp-buffer-live-bench--cache-workload () + "Revisit two historical paragraph states repeatedly." + (dotimes (_ 80) + (ekp-buffer-live-bench--insert ?x) + (ekp-buffer-live-bench--delete-backward))) + +(defun ekp-buffer-live-bench--motion-workload () + "Move point across semantic boundaries without changing projection." + (let* ((state ekp-buffer--live-state) + (transaction ekp-buffer--live-edit) + (projection (buffer-substring (point-min) (point-max))) + (active (ekp-buffer--live-state-active-index state)) + (signatures (ekp-buffer--live-state-signatures state)) + (prefix-end (ekp-buffer--live-state-prefix-end state)) + (prefix-position (marker-position prefix-end)) + (edit-end + (and transaction + (ekp-buffer--live-edit-edit-end transaction))) + (edit-position (and edit-end (marker-position edit-end)))) + (dotimes (_ 48) + (ekp-buffer-live-bench--record-command + (lambda () (call-interactively #'backward-char)) + ekp-buffer-live-bench--expected)) + (dotimes (_ 48) + (ekp-buffer-live-bench--record-command + (lambda () (call-interactively #'forward-char)) + ekp-buffer-live-bench--expected)) + (unless (and (eq ekp-buffer--live-state state) + (= (ekp-buffer--live-state-active-index state) active) + (eq (ekp-buffer--live-state-signatures state) signatures) + (eq ekp-buffer--live-edit transaction) + (eq (ekp-buffer--live-state-prefix-end state) prefix-end) + (= (marker-position prefix-end) prefix-position) + (or (null edit-end) + (and (eq (ekp-buffer--live-edit-edit-end transaction) + edit-end) + (= (marker-position edit-end) edit-position))) + (equal-including-properties + (buffer-substring (point-min) (point-max)) + projection)) + (error "Point motion changed the live projection")))) + +(defun ekp-buffer-live-bench--boundary-workload () + "Create and remove hard boundaries through public commands." + (dotimes (_ 24) + (ekp-buffer-live-bench--record-command + (lambda () (call-interactively #'newline)) + (concat ekp-buffer-live-bench--expected "\n")) + (ekp-buffer-live-bench--delete-backward))) + +(defun ekp-buffer-live-bench--instrument (workload) + "Run WORKLOAD and return planner/cache counters." + (let ((plan-calls 0) + (cache-gets 0) + (cache-hits 0) + (original-plan (symbol-function 'ekp-layout-plan)) + (original-get (symbol-function 'ekp-buffer--live-cache-get))) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (cl-incf plan-calls) + (apply original-plan arguments))) + ((symbol-function 'ekp-buffer--live-cache-get) + (lambda (key) + (cl-incf cache-gets) + (let ((plan (funcall original-get key))) + (when plan (cl-incf cache-hits)) + plan)))) + (funcall workload)) + (list :plan-calls plan-calls + :cache-hits cache-hits + :cache-misses (- cache-gets cache-hits)))) + +(defun ekp-buffer-live-bench--scenario (name workload) + "Run named live-edit WORKLOAD and return its measurements." + (with-temp-buffer + (text-mode) + (insert ekp-buffer-live-bench--base) + (goto-char (point-max)) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _window) + ekp-buffer-live-bench--width))) + (ekp-auto-justify-mode 1) + (let ((ekp-buffer-live-bench--expected + (substring-no-properties (buffer-string))) + (ekp-buffer-live-bench--times nil) + (ekp-buffer-live-bench--gc-events 0) + (ekp-buffer-live-bench--gc-seconds 0.0)) + (garbage-collect) + (unwind-protect + (append (list :name name) + (ekp-buffer-live-bench--statistics-after + workload)) + (ekp-auto-justify-mode -1)))))) + +(defun ekp-buffer-live-bench--statistics-after (workload) + "Run WORKLOAD with instrumentation and return combined statistics." + (let ((counters (ekp-buffer-live-bench--instrument workload))) + (append (ekp-buffer-live-bench--statistics) + counters + (list :source-ok t + :conflicts (length ekp-buffer--conflicts) + :cache-size (length ekp-buffer--live-plan-cache))))) + +(defun ekp-buffer-live-bench--format (result) + "Format live benchmark RESULT as one report row." + (format + "%-15s %5d %8.3f %8.3f %8.3f %8.3f %6d %6d %6d %3d %7.2f %5d %s" + (plist-get result :name) + (plist-get result :edits) + (plist-get result :median) + (plist-get result :p95) + (plist-get result :p99) + (plist-get result :max) + (plist-get result :plan-calls) + (plist-get result :cache-hits) + (plist-get result :cache-misses) + (plist-get result :gc-events) + (plist-get result :gc-ms) + (plist-get result :cache-size) + (if (<= (plist-get result :p99) 16.0) "PASS" "MISS"))) + +;;;###autoload +(defun ekp-buffer-live-bench-run () + "Run live-edit scenarios and print percentile/caching evidence." + (let ((scenarios + `(("append" . ,#'ekp-buffer-live-bench--append-workload) + ("cache-revisit" . ,#'ekp-buffer-live-bench--cache-workload) + ("point-motion" . ,#'ekp-buffer-live-bench--motion-workload) + ("hard-boundary" . ,#'ekp-buffer-live-bench--boundary-workload)))) + (princ + (format "EKP live edit benchmark: engine=%s width=%dpx\n" + (if (and ekp-use-c-module (ekp--c-available-p)) "C" "elisp") + ekp-buffer-live-bench--width)) + (princ + (concat + "scenario edits median p95 p99 max" + " plans hits misses gc gc-ms cache frame\n")) + (dolist (scenario scenarios) + (princ + (concat + (ekp-buffer-live-bench--format + (ekp-buffer-live-bench--scenario + (car scenario) (cdr scenario))) + "\n"))))) + +(ekp-buffer-live-bench-run) + +(provide 'ekp-buffer-live-bench) + +;;; ekp-buffer-live-bench.el ends here diff --git a/tests/ekp-buffer-tests.el b/tests/ekp-buffer-tests.el new file mode 100644 index 0000000..31d9c82 --- /dev/null +++ b/tests/ekp-buffer-tests.el @@ -0,0 +1,2310 @@ +;;; ekp-buffer-tests.el --- Tests for ekp-buffer.el -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Batch-safe ERT tests for the buffer-level justification layer. +;; Widths are always passed explicitly, so no window is required. + +;;; Code: + +(require 'ert) +(require 'ekp-buffer) + +(defconst ekp-buffer-test--samples + (list "简单的中文段落测试内容,排版效果应当良好稳定。" + "The quick brown fox jumps over the lazy dog several times today." + "Mixed 中英文 paragraph with double spaces inside and a tail " + "para one\n\npara two 混排 content here\nthird para" + " leading indent 段落内容 preserved intact" + "短\n\n\n多个空段落之间的内容") + "Logical texts covering CJK, Latin, mixed, blanks, indent, tails.") + +(defconst ekp-buffer-test--widths '(30 80 200 400) + "Pixel widths from emergency-narrow to comfortable.") + +(defmacro ekp-buffer-test--with-text (text &rest body) + "Run BODY in a temp buffer containing TEXT." + (declare (indent 1)) + `(with-temp-buffer + (insert ,text) + ,@body)) + +;;;; Text-property projection invariants + +(defun ekp-buffer-test--display-spec-p (name value) + "Return non-nil when display VALUE contains a NAME specification." + (or (and (consp value) (eq (car value) name)) + (and (listp value) + (seq-some (lambda (item) + (and (consp item) (eq (car item) name))) + value)))) + +(ert-deftest ekp-buffer-test-layout-never-changes-source-characters () + "Layout changes display properties, never the logical character stream." + (let ((text "中文排版 mixed words with spaces and extraordinary wrapping")) + (ekp-buffer-test--with-text text + (buffer-enable-undo) + (set-buffer-modified-p nil) + (setq buffer-undo-list nil) + (goto-char (+ (point-min) 7)) + (let ((point-before (point)) + (size-before (buffer-size)) + (chars-tick (buffer-chars-modified-tick)) + (undo-before buffer-undo-list)) + (ekp-justify-region (point-min) (point-max) 24) + (should (equal (substring-no-properties (buffer-string)) text)) + (should (= (buffer-size) size-before)) + (should (= (point) point-before)) + (should (= (buffer-chars-modified-tick) chars-tick)) + (should (eq buffer-undo-list undo-before)) + (should-not (buffer-modified-p)) + (should-not (overlays-in (point-min) (point-max))))))) + +(ert-deftest ekp-buffer-test-projection-fires-no-external-change-hooks () + "Projection installation and removal are silent to external change hooks." + (ekp-buffer-test--with-text + "silent projection hooks 中文 mixed paragraph long enough" + (let ((before 0) + (after 0)) + (add-hook 'before-change-functions + (lambda (&rest _) (setq before (1+ before))) nil t) + (add-hook 'after-change-functions + (lambda (&rest _) (setq after (1+ after))) nil t) + (ekp-justify-region (point-min) (point-max) 20) + (ekp-unjustify-region (point-min) (point-max)) + (should (= before 0)) + (should (= after 0)) + (goto-char (point-max)) + (insert "x") + (should (= before 1)) + (should (= after 1))))) + +(ert-deftest ekp-buffer-test-owned-property-publishes-once () + "A projection value and its owner marker are one property mutation." + (ekp-buffer-test--with-text "owned" + (let ((mutations 0) + (put (symbol-function 'put-text-property)) + (add (symbol-function 'add-text-properties)) + (value '(space :width (4)))) + (cl-letf (((symbol-function 'put-text-property) + (lambda (beg end property new-value &optional object) + (setq mutations (1+ mutations)) + (funcall put beg end property new-value object))) + ((symbol-function 'add-text-properties) + (lambda (beg end properties &optional object) + (setq mutations (1+ mutations)) + (funcall add beg end properties object)))) + (ekp-buffer--put-owned + (point-min) (point-max) 'display 'ekp-buffer--display value)) + (should (= mutations 1)) + (should (equal (get-text-property (point-min) 'display) value)) + (should (equal (get-text-property + (point-min) 'ekp-buffer--display) + value))))) + +(ert-deftest ekp-buffer-test-prepared-plan-installs-without-measuring () + "Projection must consume gap geometry already prepared by the plan." + (let ((text "中文 mixed paragraph with several spaces and CJK gaps")) + (ekp-buffer-test--with-text text + (let ((plan (ekp-layout-plan + (buffer-substring (point-min) (point-max)) 18))) + (cl-letf (((symbol-function 'ekp--measured-width) + (lambda (&rest _) + (error "projection measured prepared geometry")))) + (should (ekp-buffer--projectable-p + plan (point-min) (point-min) (point-max))) + (ekp-buffer--install-plan + (point-min) (point-max) 18 plan)))))) + +(ert-deftest ekp-buffer-test-justify-accepts-reversed-bounds () + "A reversed region receives the same projection as an ordered region." + (let ((text "中文排版 mixed words need several visual lines")) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-max) (point-min) 24) + (should ekp-buffer--spans) + (should (equal (substring-no-properties (buffer-string)) text)) + (should-not (text-property-not-all + (point-min) (point-max) 'ekp-justified 24))))) + +(ert-deftest ekp-buffer-test-ascii-glue-combines-space-and-min-width () + "Existing ASCII spaces use `space-width' plus an exact `min-width' floor." + (ekp-buffer-test--with-text "aa bb cc dd ee ff" + (ekp-justify-region (point-min) (point-max) 12) + (goto-char (point-min)) + (should (search-forward " " nil t)) + (let ((display (get-text-property (1- (point)) 'display))) + (pcase display + (`((space-width ,factor) (min-width ((,target)))) + (should (numberp factor)) + (should (> factor 0)) + (should (integerp target)) + (should (> target 0))) + (_ (ert-fail (format "Unexpected ASCII glue display: %S" + display))))))) + +(ert-deftest ekp-buffer-test-cjk-zero-source-glue-uses-min-width () + "CJK glue pads an existing grapheme and inserts no source space." + (let ((text "中文排版测试内容足够长")) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 11) + (should (equal (substring-no-properties (buffer-string)) text)) + (should + (seq-some + (lambda (pos) + (pcase (get-text-property pos 'display) + (`(min-width ((,target))) + (and (integerp target) (> target 0))))) + (number-sequence (point-min) (1- (point-max)))))))) + +(ert-deftest ekp-buffer-test-hyphen-and-break-are-display-only () + "A discretionary hyphen and newline live in `display', not source text." + (let ((text "extraordinary hyphenation demonstration paragraph")) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 15) + (should (equal (substring-no-properties (buffer-string)) text)) + (should (= (cl-count ?\n (buffer-string)) 0)) + (should-not (string-match-p "-" (substring-no-properties + (buffer-string)))) + (should + (seq-some + (lambda (pos) + (let ((display (get-text-property pos 'display))) + (and (stringp display) + (string-match-p "-\n" (substring-no-properties display))))) + (number-sequence (point-min) (1- (point-max)))))))) + +(ert-deftest ekp-buffer-test-indentation-uses-line-prefix () + "First-line indentation is a `line-prefix' projection." + (let ((ekp-first-line-indent 6)) + (ekp-buffer-test--with-text "首行缩进测试内容足够长会形成多行" + (ekp-justify-region (point-min) (point-max) 18) + (let ((prefix (get-text-property (point-min) 'line-prefix))) + (should (stringp prefix)) + (should (equal (get-text-property 0 'display prefix) + '(space :width (6))))) + (should (equal (substring-no-properties (buffer-string)) + "首行缩进测试内容足够长会形成多行"))))) + +(ert-deftest ekp-buffer-test-foreign-display-conflict-is-preserved () + "EKP keeps a paragraph verbatim instead of stealing foreign display state." + (ekp-buffer-test--with-text "foreign display paragraph must stay intact" + (put-text-property 9 16 'display "VISIBLE") + (let ((before (buffer-substring (point-min) (point-max)))) + (ekp-justify-region (point-min) (point-max) 12) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) before)) + (should ekp-buffer--conflicts)))) + +(ert-deftest ekp-buffer-test-foreign-projection-owners-stay-verbatim () + "Every uncomposable foreign projection owner remains exact." + (dolist (entry '((line-prefix . "P") + (wrap-prefix . "W") + (composition . ((1))) + (invisible . foreign))) + (ekp-buffer-test--with-text + "foreign owner paragraph must stay property-identical" + (put-text-property 4 8 (car entry) (cdr entry)) + (let ((before (buffer-substring (point-min) (point-max)))) + (ekp-justify-region (point-min) (point-max) 12) + (should-not ekp-buffer--spans) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) before)) + (should + (string-match-p + (format "foreign `%s' property" (car entry)) + (caddr (car ekp-buffer--conflicts)))))))) + +;;;; Roundtrip exactness + +(ert-deftest ekp-buffer-test-roundtrip-exact () + "justify + unjustify restores text and properties exactly." + (dolist (text ekp-buffer-test--samples) + (dolist (w ekp-buffer-test--widths) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) w) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal-including-properties (buffer-string) text)))))) + +(ert-deftest ekp-buffer-test-roundtrip-propertized () + "Roundtrip preserves user text properties." + (let ((text (concat (propertize "加粗的中文开头内容" 'face 'bold) + " plain middle part " + (propertize "italic tail words" 'face 'italic)))) + (dolist (w '(60 250)) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) w) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal-including-properties (buffer-string) text)))))) + +(ert-deftest ekp-buffer-test-hard-newlines-preserved () + "Hard newline count survives justification." + (ekp-buffer-test--with-text "a 段落 one\n\nb 段落 two\nc 段落 three" + (ekp-justify-region (point-min) (point-max) 100) + (let ((hard 0)) + (goto-char (point-min)) + (while (search-forward "\n" nil t) + (unless (get-text-property (match-beginning 0) 'ekp-soft-break) + (setq hard (1+ hard)))) + (should (= hard 3))))) + +;;;; Justified-state invariants + +(ert-deftest ekp-buffer-test-justified-marked () + "Justified region carries the ekp-justified width property." + (ekp-buffer-test--with-text "中文内容需要标记属性验证正确性" + (ekp-justify-region (point-min) (point-max) 120) + (should (eq (get-text-property (point-min) 'ekp-justified) 120)) + (should-not (text-property-not-all (point-min) (point-max) + 'ekp-justified 120)))) + +(ert-deftest ekp-buffer-test-rejustify-idempotent () + "Justifying at a new width equals a fresh justification at that width." + (let ((text "The idempotence check 中英混排 must hold across widths.")) + (let (fresh) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 150) + (setq fresh (buffer-string))) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 300) + (ekp-justify-region (point-min) (point-max) 150) + (should (equal-including-properties (buffer-string) fresh)))))) + +;;;; Edit robustness + +(ert-deftest ekp-buffer-test-edit-then-unjustify () + "Text typed into a justified buffer survives unjustification." + (ekp-buffer-test--with-text "abcdef ghijkl 中文内容 mnopqr stuvwx" + (ekp-justify-region (point-min) (point-max) 80) + ;; Insert inside the first word: physical == logical there. + (goto-char (+ (point-min) 2)) + (insert "XY") + (ekp-unjustify-region (point-min) (point-max)) + (should (equal (buffer-string) + "abXYcdef ghijkl 中文内容 mnopqr stuvwx")))) + +(ert-deftest ekp-buffer-test-point-stable () + "Point returns to its logical position after a roundtrip." + (ekp-buffer-test--with-text "abcdef ghijkl mnopqr stuvwx yzabcd" + (goto-char (+ (point-min) 9)) ; inside "ghijkl" + (ekp-justify-region (point-min) (point-max) 60) + (ekp-unjustify-region (point-min) (point-max)) + (should (= (point) (+ (point-min) 9))))) + +(ert-deftest ekp-buffer-test-inactive-mark-stays-inactive () + "Reprojection preserves an existing inactive mark without selecting text." + (ekp-buffer-test--with-text + "showcase width changes must not activate an old mark" + (goto-char (+ (point-min) 12)) + (set-marker (mark-marker) (+ (point-min) 3)) + (setq mark-active nil) + (let ((point-before (point)) + (mark-before (mark t))) + (ekp-justify-region (point-min) (point-max) 40) + (should (= (point) point-before)) + (should (= (mark t) mark-before)) + (should-not mark-active)))) + +(ert-deftest ekp-buffer-test-active-region-stays-active () + "Reprojection preserves an intentional active region." + (ekp-buffer-test--with-text + "an intentional region remains active across width changes" + (set-mark (+ (point-min) 3)) + (goto-char (+ (point-min) 18)) + (activate-mark) + (let ((point-before (point)) + (mark-before (mark t))) + (ekp-justify-region (point-min) (point-max) 40) + (should (= (point) point-before)) + (should (= (mark t) mark-before)) + (should mark-active) + (should (use-region-p))))) + +;;;; Auto-justify mode + +(defmacro ekp-buffer-test--with-mode (text width &rest body) + "Enable `ekp-auto-justify-mode' on TEXT at WIDTH, run BODY, disable." + (declare (indent 2)) + `(ekp-buffer-test--with-text ,text + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) ,width))) + (ekp-auto-justify-mode 1) + (unwind-protect + (progn ,@body) + (ekp-auto-justify-mode -1))))) + +(defun ekp-buffer-test--display-newline-positions () + "Return positions whose EKP-owned display value contains a newline." + (let ((pos (point-min)) + positions) + (while (< pos (point-max)) + (let* ((display (get-text-property + pos 'ekp-buffer--display)) + (next (or (next-single-property-change + pos 'ekp-buffer--display nil (point-max)) + (point-max)))) + (when (and (stringp display) + (string-match-p "\n" + (substring-no-properties display))) + (push pos positions)) + (setq pos next))) + (nreverse positions))) + +(defun ekp-buffer-test--type-string (string) + "Insert STRING through the public self-insert command path." + (mapc + (lambda (character) + (let ((last-command-event character)) + (call-interactively #'self-insert-command))) + (string-to-list string))) + +(defun ekp-buffer-test--semantic-plan (text width min-lines) + "Return TEXT's plan at WIDTH, requiring at least MIN-LINES lines." + (let* ((plan (ekp-layout-plan text width)) + (lines (ekp-layout-plan-lines plan))) + (should (>= (length lines) min-lines)) + plan)) + +(defun ekp-buffer-test--line-at (plan index) + "Return semantic line INDEX from PLAN." + (aref (ekp-layout-plan-lines plan) index)) + +(defun ekp-buffer-test--any-owned-layout-p (beg end) + "Return non-nil when BEG through END has EKP-owned projection." + (and (< beg end) + (or (text-property-not-all beg end 'ekp-justified nil) + (text-property-not-all beg end 'ekp-buffer--display nil) + (text-property-not-all beg end 'ekp-buffer--line-prefix nil)))) + +(defun ekp-buffer-test--assert-natural-range (beg end) + "Assert BEG through END carries no EKP live projection properties." + (when (< beg end) + (dolist (property + '(ekp-justified ekp-buffer--display ekp-buffer--line-prefix)) + (should-not (text-property-not-all beg end property nil))))) + +(defun ekp-buffer-test--live-active-beg () + "Return the source beginning of the current natural semantic line." + (let* ((state ekp-buffer--live-state) + (plan (ekp-buffer--live-state-plan state)) + (active (ekp-buffer--live-state-active-index state)) + (base (marker-position (ekp-buffer--live-state-beg state))) + (line (ekp-buffer-test--line-at plan active))) + (+ base (ekp-layout-line-source-start line)))) + +(defun ekp-buffer-test--first-space-break-line (plan) + "Return the first semantic line in PLAN whose break consumes spaces." + (let ((lines (ekp-layout-plan-lines plan)) + found) + (dotimes (index (length lines)) + (let ((line (aref lines index))) + (when (and (not found) + (eq (ekp-layout-line-break-kind line) 'space) + (< (ekp-layout-line-break-source-start line) + (ekp-layout-line-break-source-end line))) + (setq found line)))) + (should found) + found)) + +(ert-deftest ekp-buffer-test-live-edge-whitespace-is-immediately-visible () + "One self-inserted edge whitespace stays natural on the active line." + (dolist (case '(("alpha beta gamma" end ?\s) + ("alpha beta gamma" beginning ?\s) + ("alpha beta gamma" end ?\t) + ("中文排版测试" end ?\s))) + (pcase-let ((`(,text ,where ,character) case)) + (ekp-buffer-test--with-mode text 16 + (goto-char (if (eq where 'end) (point-max) (point-min))) + (let ((last-command-event character)) + (call-interactively #'self-insert-command)) + (should (= (char-before) character)) + (should-not (get-text-property + (1- (point)) 'ekp-buffer--display)) + (should-not (get-text-property (1- (point)) 'display)))))) + +(ert-deftest ekp-buffer-test-live-deletion-exposes-trailing-space () + "Deleting the final glyph leaves the preceding source space visible." + (ekp-buffer-test--with-mode "alpha beta x" 16 + (goto-char (point-max)) + (delete-char -1) + (should (eq (char-before) ?\s)) + (should-not (get-text-property + (1- (point)) 'ekp-buffer--display)) + (should-not (get-text-property (1- (point)) 'display)) + (should ekp-buffer--live-edit))) + +(ert-deftest ekp-buffer-test-live-consecutive-spaces-survive-backspace () + "Backspacing a following glyph leaves both typed spaces visible." + (ekp-buffer-test--with-mode "alpha beta" 16 + (goto-char (point-max)) + (dolist (character '(?\s ?\s ?x)) + (let ((last-command-event character)) + (call-interactively #'self-insert-command))) + (call-interactively #'delete-backward-char) + (should (string-suffix-p " " (buffer-string))) + (dolist (position (list (- (point) 2) (1- (point)))) + (should-not (get-text-property position 'ekp-buffer--display)) + (should-not (get-text-property position 'display))) + (should ekp-buffer--live-edit) + (should-not (overlays-in (point-min) (point-max))))) + +(ert-deftest ekp-buffer-test-live-yank-trailing-space-is-natural () + "Yanking text with a trailing space publishes it without hiding it." + (let ((kill-ring nil) + kill-ring-yank-pointer) + (ekp-buffer-test--with-mode "alpha beta" 16 + (goto-char (point-max)) + (kill-new " pasted ") + (call-interactively #'yank) + (should (equal (substring-no-properties (buffer-string)) + "alpha beta pasted ")) + (should-not (get-text-property + (1- (point)) 'ekp-buffer--display)) + (should-not (get-text-property (1- (point)) 'display)) + (should-not ekp-buffer--live-edit)))) + +(ert-deftest ekp-buffer-test-live-newline-then-space-is-natural () + "A space on a newly created hard line is visible immediately." + (ekp-buffer-test--with-mode "alpha beta" 16 + (goto-char (point-max)) + (call-interactively #'newline) + (let ((last-command-event ?\s)) + (call-interactively #'self-insert-command)) + (should (string-suffix-p "\n " (buffer-string))) + (should-not (get-text-property + (1- (point)) 'ekp-buffer--display)) + (should-not (get-text-property (1- (point)) 'display)) + (should ekp-buffer--live-edit))) + +(ert-deftest ekp-buffer-test-live-single-space-undo-restores-breaks () + "Undoing one edge space restores source and prior visual breaks." + (ekp-buffer-test--with-mode "alpha beta gamma delta" 16 + (buffer-enable-undo) + (setq buffer-undo-list nil) + (let ((text (substring-no-properties (buffer-string))) + (breaks (ekp-buffer-test--display-newline-positions))) + (goto-char (point-max)) + (undo-boundary) + (let ((last-command-event ?\s)) + (call-interactively #'self-insert-command)) + (undo-boundary) + (undo-only 1) + (should (equal (substring-no-properties (buffer-string)) text)) + (should (equal + (ekp-buffer-test--display-newline-positions) breaks)) + (should-not ekp-buffer--live-edit) + (should-not (overlays-in (point-min) (point-max)))))) + +(ert-deftest ekp-buffer-test-live-single-line-quick-proof-does-no-layout-work () + "Typing a provably single semantic line neither plans nor projects." + (ekp-buffer-test--with-mode "" 1000 + (let ((calls 0) + (original (symbol-function 'ekp-layout-plan))) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (mapc + (lambda (character) + (let ((last-command-event character)) + (call-interactively #'self-insert-command))) + (string-to-list "natural mixed 中文 editing")) + (should (= calls 0)))) + (dolist (property + '(ekp-justified ekp-buffer--display + ekp-buffer--line-prefix)) + (should-not + (text-property-not-all + (point-min) (point-max) property nil))) + (should-not (overlays-in (point-min) (point-max))))) + +(ert-deftest ekp-buffer-test-live-projects-prefix-before-last-plan-line () + "Point in the last semantic line projects only earlier plan lines." + (let* ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20) + (plan (ekp-buffer-test--semantic-plan text width 3)) + (lines (ekp-layout-plan-lines plan)) + (last-line (ekp-buffer-test--line-at plan (1- (length lines)))) + (active-beg (+ (point-min) + (ekp-layout-line-source-start last-line)))) + (ekp-buffer-test--with-mode "" width + (ekp-buffer-test--type-string text) + (should (ekp-buffer-test--any-owned-layout-p (point-min) active-beg)) + (ekp-buffer-test--assert-natural-range active-beg (point-max)) + (should-not (overlays-in (point-min) (point-max))) + (should (equal (substring-no-properties (buffer-string)) text))))) + +(ert-deftest ekp-buffer-test-live-middle-edit-preserves-unaffected-anchors () + "A middle-line edit naturalizes one island without clearing later anchors." + (let* ((text + (concat + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu " + "nu xi omicron pi rho sigma tau upsilon phi chi psi omega")) + (width 20) + (plan (ekp-buffer-test--semantic-plan text width 4)) + (middle (ekp-buffer-test--line-at plan 1)) + (point-offset (1+ (ekp-layout-line-source-start middle))) + (expected (concat (substring text 0 point-offset) + "X" + (substring text point-offset))) + (calls 0) + (original (symbol-function 'ekp-layout-plan))) + (ekp-buffer-test--with-mode "" width + (ekp-buffer-test--type-string text) + (ekp-buffer--commit-live-paragraph) + (let* ((spans (ekp-buffer--live-state-spans ekp-buffer--live-state)) + (dirty (nth 1 spans)) + (later (nth 2 spans)) + (later-beg (copy-marker + (marker-position (ekp-buffer--span-beg later)))) + (later-end (copy-marker + (marker-position + (ekp-buffer--span-end (car (last spans)))) + t)) + (later-projection + (buffer-substring later-beg later-end))) + (unwind-protect + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (goto-char (+ (point-min) point-offset)) + (let ((last-command-event ?X)) + (call-interactively #'self-insert-command)) + (should (= calls 0)) + (ekp-buffer-test--assert-natural-range + (marker-position (ekp-buffer--span-beg dirty)) + (marker-position (ekp-buffer--span-end dirty))) + (should (equal-including-properties + (buffer-substring later-beg later-end) + later-projection))) + (set-marker later-beg nil) + (set-marker later-end nil))) + (should (equal (substring-no-properties (buffer-string)) expected))))) + +(ert-deftest ekp-buffer-test-live-break-space-belongs-to-previous-owner-line () + "Editing break whitespace makes its semantic owner line natural." + (let* ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20) + (plan (ekp-buffer-test--semantic-plan text width 3)) + (line (ekp-buffer-test--first-space-break-line plan)) + (offset (ekp-layout-line-break-source-start line)) + (expected (concat (substring text 0 offset) + "X" + (substring text offset)))) + (ekp-buffer-test--with-mode "" width + (ekp-buffer-test--type-string text) + (should (ekp-buffer-test--any-owned-layout-p + (point-min) (point-max))) + (goto-char (+ (point-min) offset)) + (let ((last-command-event ?X)) + (call-interactively #'self-insert-command)) + (ekp-buffer-test--assert-natural-range + (ekp-buffer-test--live-active-beg) (point-max)) + (should (equal (substring-no-properties (buffer-string)) expected))))) + +(ert-deftest ekp-buffer-test-live-leading-space-belongs-to-first-line () + "Editing leading space naturalizes its row and keeps later anchors." + (let ((text + " alpha beta gamma delta epsilon zeta eta theta iota kappa lambda") + (width 20)) + (ekp-buffer-test--semantic-plan text width 3) + (ekp-buffer-test--with-mode "" width + (ekp-buffer-test--type-string text) + (ekp-buffer--commit-live-paragraph) + (should (ekp-buffer-test--any-owned-layout-p + (point-min) (point-max))) + (let* ((spans (ekp-buffer--live-state-spans ekp-buffer--live-state)) + (first (car spans)) + (later (cadr spans))) + (goto-char (point-min)) + (let ((last-command-event ?X)) + (call-interactively #'self-insert-command)) + (should ekp-buffer--live-edit) + (ekp-buffer-test--assert-natural-range + (marker-position (ekp-buffer--span-beg first)) + (marker-position (ekp-buffer--span-end first))) + (should (get-text-property + (marker-position (ekp-buffer--span-beg later)) + 'ekp-justified))) + (should (equal (substring-no-properties (buffer-string)) + (concat "X" text)))))) + +(ert-deftest ekp-buffer-test-live-earlier-breaks-are-not-frozen () + "Later edits can invalidate an earlier semantic line signature." + (let* ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (prefix "extraordinarysuperlong ") + (width 20) + (old-plan (ekp-buffer-test--semantic-plan text width 3)) + (new-plan (ekp-buffer-test--semantic-plan + (concat prefix text) width 3)) + (old-signature + (ekp-layout-line-signature + (ekp-buffer-test--line-at old-plan 0))) + (new-signature + (ekp-layout-line-signature + (ekp-buffer-test--line-at new-plan 0)))) + (should-not (equal old-signature new-signature)) + (ekp-buffer-test--with-mode "" width + (ekp-buffer-test--type-string text) + (should (ekp-buffer-test--any-owned-layout-p + (point-min) (point-max))) + (let ((old-breaks (ekp-buffer-test--display-newline-positions))) + (goto-char (point-min)) + (ekp-buffer-test--type-string prefix) + (goto-char (point-max)) + (run-hooks 'post-command-hook) + (should-not (equal + old-breaks + (ekp-buffer-test--display-newline-positions))) + (should (equal (substring-no-properties (buffer-string)) + (concat prefix text))))))) + +(ert-deftest ekp-buffer-test-live-same-plan-does-not-rewrite-properties () + "Posting the same plan/prefix boundary performs zero owned writes." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20) + (writes 0) + (put (symbol-function 'put-text-property)) + (remove (symbol-function 'remove-text-properties))) + (ekp-buffer-test--semantic-plan text width 3) + (ekp-buffer-test--with-mode "" width + (ekp-buffer-test--type-string text) + (should (ekp-buffer-test--any-owned-layout-p + (point-min) (point-max))) + (buffer-enable-undo) + (setq buffer-undo-list nil) + (set-buffer-modified-p nil) + (let ((chars-tick (buffer-chars-modified-tick)) + (undo-before buffer-undo-list)) + (cl-letf (((symbol-function 'put-text-property) + (lambda (beg end property value &optional object) + (when (memq property + '(ekp-justified display line-prefix)) + (setq writes (1+ writes))) + (funcall put beg end property value object))) + ((symbol-function 'remove-text-properties) + (lambda (beg end properties &optional object) + (when (seq-some + (lambda (property) + (memq property + '(ekp-justified display line-prefix))) + properties) + (setq writes (1+ writes))) + (funcall remove beg end properties object)))) + (run-hooks 'post-command-hook)) + (should (= writes 0)) + (should (= (buffer-chars-modified-tick) chars-tick)) + (should-not (buffer-modified-p)) + (should (eq buffer-undo-list undo-before)))))) + +(ert-deftest ekp-buffer-test-live-same-row-edit-keeps-committed-projection () + "Ordinary same-row input neither replans nor rewrites the committed prefix." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20) + (calls 0) + (original (symbol-function 'ekp-layout-plan))) + (ekp-buffer-test--with-mode text width + (let* ((state ekp-buffer--live-state) + (prefix-end + (copy-marker + (marker-position (ekp-buffer--live-state-prefix-end state)))) + (projection (buffer-substring (point-min) prefix-end))) + (unwind-protect + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (goto-char + (1+ (marker-position + (ekp-buffer--live-state-prefix-end state)))) + (let ((last-command-event ?x)) + (call-interactively #'self-insert-command)) + (should (= calls 0)) + (should (eq ekp-buffer--live-state state)) + (should ekp-buffer--live-edit) + (should (equal-including-properties + (buffer-substring (point-min) prefix-end) + projection))) + (set-marker prefix-end nil)))))) + +(ert-deftest ekp-buffer-test-live-wrap-crossing-is-one-atomic-commit () + "Typing across one native row boundary plans once, not after every key." + (let ((calls 0) + (original (symbol-function 'ekp-layout-plan))) + (ekp-buffer-test--with-mode "" 20 + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (ekp-buffer-test--type-string "alpha beta gamma delt") + (should (= calls 1)) + (should (ekp-buffer-test--any-owned-layout-p + (point-min) (point-max))) + (ekp-buffer-test--type-string "a") + (should (= calls 1)) + (should-not (get-text-property + (1- (point)) 'ekp-buffer--display)))))) + +(ert-deftest ekp-buffer-test-live-backward-wrap-crossing-commits-once () + "Deleting into the previous native row publishes one atomic plan." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (calls 0) + (original (symbol-function 'ekp-layout-plan))) + (ekp-buffer-test--with-mode text 20 + (goto-char (point-max)) + (insert " xyz") + (ekp-buffer--commit-live-paragraph) + (let ((size-before (buffer-size))) + (cl-letf (((symbol-function 'ekp-buffer--native-row-start) + (lambda (_) + (if (= (buffer-size) size-before) 40 20))) + ((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (call-interactively #'delete-backward-char) + (should (= calls 1)) + (should-not ekp-buffer--live-edit) + (should (equal (substring-no-properties (buffer-string)) + (concat text " xy")))))))) + +(ert-deftest ekp-buffer-test-live-reversible-edit-restores-exact-baseline () + "Deleting and reinserting one space restores the exact committed state." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")) + (ekp-buffer-test--with-mode text 20 + (let* ((state ekp-buffer--live-state) + (spans (ekp-buffer--live-state-spans state)) + (middle (nth 1 spans)) + (beg (marker-position (ekp-buffer--span-beg middle))) + (end (marker-position (ekp-buffer--span-end middle))) + (space (save-excursion + (goto-char beg) + (search-forward " " end t))) + (baseline (buffer-substring (point-min) (point-max))) + (plan (ekp-buffer--live-state-plan state)) + (signatures (ekp-buffer--live-state-signatures state))) + (should space) + (goto-char (1- space)) + (delete-char 1) + (should ekp-buffer--live-edit) + (should (equal-including-properties + (ekp-buffer--live-edit-baseline-source + ekp-buffer--live-edit) + baseline)) + (should (eq (ekp-buffer--live-edit-baseline-plan + ekp-buffer--live-edit) + plan)) + (should (eq (ekp-buffer--live-edit-baseline-signatures + ekp-buffer--live-edit) + signatures)) + (should (eq (ekp-buffer--live-edit-baseline-spans + ekp-buffer--live-edit) + spans)) + (let ((last-command-event ?\s)) + (call-interactively #'self-insert-command)) + (should-not ekp-buffer--live-edit) + (should (eq ekp-buffer--live-state state)) + (should (eq (ekp-buffer--live-state-plan state) plan)) + (should (eq (ekp-buffer--live-state-signatures state) signatures)) + (should (eq (ekp-buffer--live-state-spans state) spans)) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) + baseline)))))) + +(ert-deftest ekp-buffer-test-point-leaving-paragraph-is-zero-work () + "Cursor motion across hard lines cannot commit or rewrite live state." + (let ((text + (concat + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu" + "\nsecond paragraph")) + (calls 0) + (writes 0) + (original (symbol-function 'ekp-layout-plan)) + (put (symbol-function 'put-text-property)) + (remove (symbol-function 'remove-text-properties))) + (ekp-buffer-test--with-mode text 20 + (goto-char (point-min)) + (let ((last-command-event ?x)) + (call-interactively #'self-insert-command)) + (let ((state ekp-buffer--live-state) + (transaction ekp-buffer--live-edit) + (projection (buffer-substring (point-min) (point-max)))) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments))) + ((symbol-function 'put-text-property) + (lambda (beg end property value &optional object) + (when (memq property + '(ekp-justified display line-prefix)) + (setq writes (1+ writes))) + (funcall put beg end property value object))) + ((symbol-function 'remove-text-properties) + (lambda (beg end properties &optional object) + (when (seq-some + (lambda (property) + (memq property + '(ekp-justified display line-prefix))) + properties) + (setq writes (1+ writes))) + (funcall remove beg end properties object)))) + (goto-char (point-max)) + (run-hooks 'post-command-hook) + (should (= calls 0)) + (should (= writes 0)) + (should (eq ekp-buffer--live-state state)) + (should (eq ekp-buffer--live-edit transaction)) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) + projection))))))) + +(ert-deftest ekp-buffer-test-live-point-motion-preserves-projection () + "Moving point within one hard line changes no live layout state." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20) + (calls 0) + (key-calls 0) + (cache-gets 0) + (writes 0) + (original (symbol-function 'ekp-layout-plan)) + (original-key (symbol-function 'ekp--para-key)) + (original-get (symbol-function 'ekp-buffer--live-cache-get)) + (put (symbol-function 'put-text-property)) + (remove (symbol-function 'remove-text-properties))) + (let* ((plan (ekp-buffer-test--semantic-plan text width 3)) + (line (ekp-buffer-test--line-at plan 1)) + (target-offset (1+ (ekp-layout-line-source-start line)))) + (ekp-buffer-test--with-mode text width + (goto-char + (1+ (marker-position + (ekp-buffer--live-state-prefix-end ekp-buffer--live-state)))) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest args) + (setq calls (1+ calls)) + (apply original args))) + ((symbol-function 'ekp--para-key) + (lambda (&rest args) + (setq key-calls (1+ key-calls)) + (apply original-key args))) + ((symbol-function 'ekp-buffer--live-cache-get) + (lambda (key) + (setq cache-gets (1+ cache-gets)) + (funcall original-get key))) + ((symbol-function 'put-text-property) + (lambda (beg end property value &optional object) + (when (memq property + '(ekp-justified display line-prefix)) + (setq writes (1+ writes))) + (funcall put beg end property value object))) + ((symbol-function 'remove-text-properties) + (lambda (beg end properties &optional object) + (when (seq-some + (lambda (property) + (memq property + '(ekp-justified display line-prefix))) + properties) + (setq writes (1+ writes))) + (funcall remove beg end properties object)))) + (insert "x") + (should (= calls 0)) + (run-hooks 'post-command-hook) + (let* ((state ekp-buffer--live-state) + (transaction ekp-buffer--live-edit) + (projection (buffer-substring (point-min) (point-max))) + (active (ekp-buffer--live-state-active-index state)) + (signatures (ekp-buffer--live-state-signatures state)) + (spans (ekp-buffer--live-state-spans state)) + (edit-end + (ekp-buffer--live-edit-edit-end transaction)) + (edit-position (marker-position edit-end)) + (prefix-end + (marker-position + (ekp-buffer--live-state-prefix-end state))) + (chars-tick (buffer-chars-modified-tick)) + (undo-before buffer-undo-list)) + (setq calls 0 key-calls 0 cache-gets 0 writes 0) + (dolist (position + (list (+ (point-min) target-offset) (point-max))) + (goto-char position) + (run-hooks 'post-command-hook) + (should (eq ekp-buffer--live-state state)) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) + projection)) + (should (= (ekp-buffer--live-state-active-index state) + active)) + (should (eq (ekp-buffer--live-state-signatures state) + signatures)) + (should (eq (ekp-buffer--live-state-spans state) spans)) + (should (eq ekp-buffer--live-edit transaction)) + (should (eq (ekp-buffer--live-edit-edit-end transaction) + edit-end)) + (should (= (marker-position edit-end) edit-position)) + (should (= (marker-position + (ekp-buffer--live-state-prefix-end state)) + prefix-end))) + (should (= calls 0)) + (should (= key-calls 0)) + (should (= cache-gets 0)) + (should (= writes 0)) + (should (= (buffer-chars-modified-tick) chars-tick)) + (should (eq buffer-undo-list undo-before)) + (should (= (length (substring-no-properties (buffer-string))) + (1+ (length text)))))))))) + +(ert-deftest ekp-buffer-test-live-reflow-commits-latest-edit-boundary () + "Width reflow commits the edit boundary, never transient point." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")) + (ekp-buffer-test--with-mode text 20 + (goto-char + (1+ (marker-position + (ekp-buffer--live-state-prefix-end ekp-buffer--live-state)))) + (insert "x") + (let ((edit-position + (marker-position + (ekp-buffer--live-edit-edit-end ekp-buffer--live-edit)))) + (goto-char (point-min)) + (run-hooks 'post-command-hook) + (ekp-buffer--reflow (current-buffer) 16) + (let* ((state ekp-buffer--live-state) + (plan (ekp-buffer--live-state-plan state)) + (line-count (length (ekp-layout-plan-lines plan)))) + (should-not ekp-buffer--live-edit) + (should (= (ekp-buffer--live-state-active-index state) + line-count)) + (should (= (marker-position + (ekp-buffer--live-state-prefix-end state)) + (marker-position (ekp-buffer--live-state-end state)))) + (should (ekp-buffer--live-state-contains-p edit-position)) + (should (= (point) (point-min)))))))) + +(ert-deftest ekp-buffer-test-live-plan-cache-is-bounded-and-contextual () + "Historical live plans reuse equal keys and bound retained history." + (ekp-buffer-test--with-text "" + (let ((calls 0) + (original (symbol-function 'ekp-layout-plan)) + first) + (setq ekp-buffer--live-plan-cache nil) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (setq first (ekp-buffer--live-plan-entry + "alpha beta gamma" 40)) + (should + (eq (cdr first) + (cdr (ekp-buffer--live-plan-entry + "alpha beta gamma" 40)))) + (should (= calls 1)) + (ekp-buffer--live-plan-entry "alpha beta gamma" 41) + (let ((styled (copy-sequence "alpha beta gamma"))) + (put-text-property 0 5 'face 'bold styled) + (ekp-buffer--live-plan-entry styled 40)) + (should (= calls 3)) + (let ((ekp-alignment 'center)) + (ekp-buffer--live-plan-entry "alpha beta gamma" 40)) + (should (= calls 4)) + (dotimes (index 20) + (ekp-buffer--live-plan-entry + (format "cache paragraph %02d alpha beta" index) 40)) + (should (= (length ekp-buffer--live-plan-cache) 16)) + (should-not + (seq-find + (lambda (entry) + (equal-including-properties + (caar entry) "alpha beta gamma")) + ekp-buffer--live-plan-cache)))))) + +(ert-deftest ekp-buffer-test-live-yank-projects-semantic-prefix-only () + "One public yank projects completed semantic lines and keeps tail natural." + (let* ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20) + (plan (ekp-buffer-test--semantic-plan text width 3)) + (lines (ekp-layout-plan-lines plan)) + (active (aref lines (1- (length lines)))) + (active-beg (+ (point-min) + (ekp-layout-line-source-start active))) + (kill-ring nil) + kill-ring-yank-pointer) + (ekp-buffer-test--with-mode "" width + (kill-new text) + (call-interactively #'yank) + (should (ekp-buffer-test--any-owned-layout-p + (point-min) active-beg)) + (ekp-buffer-test--assert-natural-range active-beg (point-max)) + (should (equal (substring-no-properties (buffer-string)) text)) + (should-not (overlays-in (point-min) (point-max)))))) + +(ert-deftest ekp-buffer-test-explicit-refill-survives-post-command () + "Explicit full refill is not downgraded until the next source edit." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (width 20)) + (ekp-buffer-test--with-mode text width + (ekp-refill-paragraph) + (should-not ekp-buffer--live-state) + (let ((full-layout (buffer-substring (point-min) (point-max)))) + (run-hooks 'post-command-hook) + (should-not ekp-buffer--live-state) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) + full-layout))) + (goto-char (point-max)) + (insert "x") + (should ekp-buffer--live-state)))) + +(ert-deftest ekp-buffer-test-live-conflict-pulls-whole-hard-line-native () + "A live foreign owner removes the entire affected hard-line prefix." + (let ((text + "first alpha beta gamma\nsecond delta epsilon zeta eta theta")) + (ekp-buffer-test--with-mode text 20 + (goto-char (point-min)) + (search-forward "\n") + (let* ((second-beg (point)) + (foreign (+ second-beg 7)) + (first-before + (buffer-substring (point-min) (1- second-beg)))) + (let ((ekp-buffer--inhibit t)) + (with-silent-modifications + (put-text-property foreign (1+ foreign) + 'display "VISIBLE"))) + (goto-char (point-max)) + (insert "x") + (ekp-buffer--commit-live-paragraph) + (should (equal (get-text-property foreign 'display) "VISIBLE")) + (ekp-buffer-test--assert-natural-range second-beg (point-max)) + (should (equal-including-properties + (buffer-substring (point-min) (1- second-beg)) + first-before)) + (should ekp-buffer--conflicts))))) + +(ert-deftest ekp-buffer-test-live-span-end-does-not-absorb-active-input () + "A live semantic span ends before text inserted at its boundary." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")) + (ekp-buffer-test--with-mode "" 20 + (ekp-buffer-test--type-string text) + (let* ((span (car (ekp-buffer--live-state-spans + ekp-buffer--live-state))) + (end (ekp-buffer--span-end span))) + (should (markerp end)) + (should-not (marker-insertion-type end)))))) + +(ert-deftest ekp-buffer-test-live-install-error-rolls-back-all-spans () + "A partial live install leaves no property or orphan span and re-signals." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (calls 0) + (original (symbol-function 'ekp-buffer--project-line))) + (ekp-buffer-test--with-mode "" 20 + (ekp-buffer-test--type-string text) + (ekp-buffer--clear-live-projection (point-min) (point-max)) + (setf (ekp-buffer--live-state-signatures ekp-buffer--live-state) nil + (ekp-buffer--live-state-active-index ekp-buffer--live-state) nil) + (cl-letf (((symbol-function 'ekp-buffer--project-line) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (when (= calls 2) + (error "forced live install failure")) + (apply original arguments)))) + (should-error (ekp-buffer--publish-live-prefix) + :type 'error)) + (should (= calls 2)) + (should-not ekp-buffer--spans) + (should-not (ekp-buffer--live-state-spans ekp-buffer--live-state)) + (ekp-buffer-test--assert-natural-range (point-min) (point-max))))) + +(ert-deftest ekp-buffer-test-live-crossing-size-limit-records-conflict () + "A hard line that grows past the live limit fails closed with diagnosis." + (let ((ekp-auto-justify-paragraph-limit 20)) + (ekp-buffer-test--with-mode "" 1000 + (ekp-buffer-test--type-string (make-string 21 ?a)) + (ekp-buffer--commit-live-paragraph) + (should-not ekp-buffer--spans) + (ekp-buffer-test--assert-natural-range (point-min) (point-max)) + (should + (string-match-p + "automatic paragraph limit" + (caddr (car ekp-buffer--conflicts))))))) + +(ert-deftest ekp-buffer-test-live-teardown-releases-cache-and-markers () + "Disabling live mode detaches its markers, cache, and projection." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + markers) + (ekp-buffer-test--with-text text + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 20)) + ((symbol-function 'ekp-buffer--native-row-start) + (lambda (_) (point-min)))) + (ekp-auto-justify-mode 1) + (goto-char (point-max)) + (insert "x") + (setq markers + (append + (list (ekp-buffer--live-state-beg ekp-buffer--live-state) + (ekp-buffer--live-state-end ekp-buffer--live-state) + (ekp-buffer--live-state-prefix-end + ekp-buffer--live-state)) + (list + (ekp-buffer--live-edit-old-beg ekp-buffer--live-edit) + (ekp-buffer--live-edit-dirty-beg ekp-buffer--live-edit) + (ekp-buffer--live-edit-dirty-end ekp-buffer--live-edit) + (ekp-buffer--live-edit-row-start ekp-buffer--live-edit) + (ekp-buffer--live-edit-edit-end ekp-buffer--live-edit)))) + (should ekp-buffer--live-plan-cache) + (ekp-auto-justify-mode -1)) + (should-not ekp-buffer--live-state) + (should-not ekp-buffer--live-plan-cache) + (dolist (marker markers) + (should-not (marker-buffer marker))) + (ekp-buffer-test--assert-natural-range (point-min) (point-max)) + (should (equal (substring-no-properties (buffer-string)) + (concat text "x")))))) + +(ert-deftest ekp-buffer-test-live-hard-newline-finalizes-previous-paragraph () + "A hard newline permits full KP only for the completed paragraph." + (ekp-buffer-test--with-mode "alpha beta gamma" 80 + (goto-char (point-max)) + (call-interactively #'newline) + (let ((newline (1- (point)))) + (should (get-text-property (point-min) 'ekp-justified)) + (should-not + (text-property-not-all + (1+ newline) (point-max) 'ekp-justified nil)) + (should-not (overlays-in (point-min) (point-max)))))) + +(ert-deftest ekp-buffer-test-live-edit-never-calls-whole-paragraph-command () + "An ordinary live edit must not dispatch through `ekp-justify-region'." + (ekp-buffer-test--with-mode + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda" + 15 + (let ((called nil) + (original (symbol-function 'ekp-justify-region))) + (cl-letf (((symbol-function 'ekp-justify-region) + (lambda (&rest args) + (setq called args) + (apply original args)))) + (goto-char (point-max)) + (insert " x") + (should-not called) + (should ekp-buffer--live-edit))))) + +(ert-deftest ekp-buffer-test-live-composition-defers-and-cancels-stale-generation () + "IME preedit defers projection and an older retry cannot publish." + (let ((text "alpha beta gamma delta epsilon")) + (ekp-buffer-test--with-mode text 16 + (let ((buffer (current-buffer)) + first-generation second-generation + first-beg first-end second-beg second-end edit-end-marker) + (cl-letf (((symbol-function 'ekp-buffer--composing-p) + (lambda () t))) + (goto-char (point-max)) + (insert " x") + (setq first-generation ekp-buffer--generation) + (setq first-end (point) + first-beg (- first-end 2)) + (should ekp-buffer--live-edit) + (should (timerp ekp-buffer--composition-timer)) + (insert " y") + (setq second-generation ekp-buffer--generation) + (setq second-end (point) + second-beg (- second-end 2) + edit-end-marker + (ekp-buffer--live-edit-edit-end ekp-buffer--live-edit)) + (should (> second-generation first-generation)) + (should (= (marker-position edit-end-marker) second-end)) + (when (timerp ekp-buffer--composition-timer) + (cancel-timer ekp-buffer--composition-timer) + (setq ekp-buffer--composition-timer nil))) + (goto-char (point-min)) + (ekp-buffer--retry-composition + buffer first-generation first-beg first-end) + (should ekp-buffer--live-edit) + (should (= (marker-position edit-end-marker) second-end)) + (ekp-buffer--retry-composition + buffer second-generation second-beg second-end) + (should-not ekp-buffer--live-edit) + (should-not (marker-buffer edit-end-marker)) + (should (> (ekp-buffer--live-state-active-index + ekp-buffer--live-state) + 0)) + (should (equal (substring-no-properties (buffer-string)) + (concat text " x y"))))))) + +(ert-deftest ekp-buffer-test-live-next-edit-elsewhere-commits-quality-layout () + "The next source edit elsewhere commits and detaches the prior hard line." + (let ((text + "alpha beta gamma delta epsilon\nsecond paragraph stays here")) + (ekp-buffer-test--with-mode text 16 + (cl-letf (((symbol-function 'ekp-buffer--native-row-start) + (lambda (_) (line-beginning-position)))) + (goto-char (1+ (point-min))) + (insert "x") + (let ((first-state ekp-buffer--live-state) + (first-beg + (ekp-buffer--live-state-beg ekp-buffer--live-state))) + (forward-line 1) + (run-hooks 'post-command-hook) + (should (eq ekp-buffer--live-state first-state)) + (insert "y") + (should-not (eq ekp-buffer--live-state first-state)) + (should-not (marker-buffer first-beg)) + (should (ekp-buffer--live-state-contains-p (point))) + (should (get-text-property (point-min) 'ekp-justified))))))) + +(ert-deftest ekp-buffer-test-live-foreign-display-abandons-only-its-paragraph () + "A live conflict keeps foreign display and leaves other paragraphs stable." + (let ((text + "first paragraph alpha beta gamma\nsecond paragraph delta epsilon")) + (ekp-buffer-test--with-mode text 16 + (goto-char (point-min)) + (search-forward "\n") + (let* ((second-beg (point)) + (first (buffer-substring (point-min) (1- second-beg))) + (foreign (+ second-beg 7))) + (let ((ekp-buffer--inhibit t)) + (with-silent-modifications + (put-text-property foreign (1+ foreign) + 'display "VISIBLE"))) + (goto-char (point-max)) + (insert " x") + (should (equal (get-text-property foreign 'display) "VISIBLE")) + (should (equal-including-properties + (buffer-substring (point-min) (1- second-beg)) + first)) + (ekp-buffer--commit-live-paragraph) + (should ekp-buffer--conflicts) + (should-not (get-text-property second-beg 'ekp-justified)))))) + +(ert-deftest ekp-buffer-test-live-uses-one-narrowest-window-width () + "Every live span follows the buffer's narrowest displayed window." + (ekp-buffer-test--with-text + "alpha beta gamma delta epsilon zeta eta theta" + (cl-letf (((symbol-function 'get-buffer-window-list) + (lambda (&rest _) '(wide narrow))) + ((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional window) + (if (eq window 'wide) 40 16)))) + (ekp-auto-justify-mode 1) + (unwind-protect + (progn + (should (= ekp-buffer--auto-width 16)) + (goto-char (point-max)) + (insert " x") + (should + (cl-every + (lambda (span) + (= (ekp-buffer--span-width span) 16)) + ekp-buffer--spans))) + (ekp-auto-justify-mode -1))))) + +(ert-deftest ekp-buffer-test-live-narrowing-preserves-outside-source () + "A narrowed edit preserves outside source while committing prior live rows." + (let ((text + "first paragraph alpha beta\nmiddle paragraph gamma delta\nlast paragraph epsilon zeta")) + (ekp-buffer-test--with-mode text 16 + (goto-char (point-min)) + (let* ((first-end (progn (search-forward "\n") (1- (point)))) + (middle-beg (point)) + (middle-end (progn (search-forward "\n") (1- (point)))) + (last-beg (point)) + (first-before + (buffer-substring (point-min) first-end)) + (last-start (copy-marker last-beg)) + (last-end (copy-marker (point-max) t)) + (last-before (buffer-substring last-beg (point-max)))) + (narrow-to-region middle-beg middle-end) + (goto-char (point-min)) + (search-forward "gamma") + (insert "x") + (save-restriction + (widen) + (should (equal-including-properties + (buffer-substring (point-min) first-end) + first-before)) + (should + (equal + (substring-no-properties + (buffer-substring + (marker-position last-start) + (marker-position last-end))) + (substring-no-properties last-before))) + (should (get-text-property + (marker-position last-start) 'ekp-justified))) + (set-marker last-start nil) + (set-marker last-end nil))))) + +(ert-deftest ekp-buffer-test-narrowed-reflow-reprojects-whole-buffer () + "A resize reflow preserves narrowing while updating outside paragraphs." + (let ((text + (concat + "first alpha beta gamma delta epsilon\n" + "middle zeta eta theta iota kappa lambda\n" + "last mu nu xi omicron pi rho sigma"))) + (ekp-buffer-test--with-mode text 20 + (goto-char (point-min)) + (let* ((first (copy-marker (point-min))) + (middle-beg (progn (forward-line 1) (point))) + (middle-end (line-end-position)) + (last (progn (forward-line 1) (copy-marker (point))))) + (narrow-to-region middle-beg middle-end) + (ekp-buffer--reflow (current-buffer) 16) + (should (= (point-min) middle-beg)) + (should (= (point-max) middle-end)) + (save-restriction + (widen) + (should (= (get-text-property first 'ekp-justified) 16)) + (should (= (get-text-property last 'ekp-justified) 16)) + (should (equal (substring-no-properties (buffer-string)) text))) + (set-marker first nil) + (set-marker last nil))))) + +(ert-deftest ekp-buffer-test-live-resize-keeps-latest-effective-width () + "A stale resize callback cannot permanently lose the latest width." + (let ((text "alpha beta gamma delta epsilon zeta")) + (ekp-buffer-test--with-mode text 40 + (goto-char (point-max)) + (let ((scheduled-generation ekp-buffer--generation)) + (insert " x") + (cl-letf (((symbol-function 'ekp-buffer--effective-width) + (lambda (&optional _) 12))) + (ekp-buffer--reflow + (current-buffer) 12 scheduled-generation)) + (should (= ekp-buffer--auto-width 12)) + (should (equal (substring-no-properties (buffer-string)) + (concat text " x"))))))) + +(ert-deftest ekp-buffer-test-live-resize-publishes-active-paragraph-once () + "Resize must not install then replace a static active-paragraph plan." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda") + (static-installs 0) + (live-installs 0) + (install-plan (symbol-function 'ekp-buffer--install-plan)) + (install-live (symbol-function 'ekp-buffer--install-live-prefix))) + (ekp-buffer-test--with-mode text 40 + (cl-letf (((symbol-function 'ekp-buffer--install-plan) + (lambda (&rest arguments) + (setq static-installs (1+ static-installs)) + (apply install-plan arguments))) + ((symbol-function 'ekp-buffer--install-live-prefix) + (lambda (&rest arguments) + (setq live-installs (1+ live-installs)) + (apply install-live arguments)))) + (ekp-buffer--reflow (current-buffer) 20)) + (should (= static-installs 0)) + (should (= live-installs 1)) + (should (equal (substring-no-properties (buffer-string)) text))))) + +(ert-deftest ekp-buffer-test-font-context-change-invalidates-live-plan () + "Theme and frame-font changes rebuild every displayed auto buffer." + (let ((text + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda") + (calls 0) + (original (symbol-function 'ekp-layout-plan))) + (should + (memq #'ekp-buffer--on-font-context-change enable-theme-functions)) + (should + (memq #'ekp-buffer--on-font-context-change disable-theme-functions)) + (should + (memq #'ekp-buffer--on-font-context-change after-setting-font-hook)) + (ekp-buffer-test--with-mode text 20 + (goto-char (point-max)) + (insert "x") + (let ((old-plan (ekp-buffer--live-state-plan ekp-buffer--live-state))) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest arguments) + (setq calls (1+ calls)) + (apply original arguments)))) + (run-hook-with-args 'enable-theme-functions 'ekp-test-theme)) + (should (> calls 0)) + (should-not + (eq old-plan + (ekp-buffer--live-state-plan ekp-buffer--live-state))) + (should (equal (substring-no-properties (buffer-string)) + (concat text "x"))))))) + +(ert-deftest ekp-buffer-test-mode-owns-and-restores-native-soft-wrap () + "Auto mode soft-wraps narrow windows and restores prior ownership." + (ekp-buffer-test--with-text "alpha beta gamma delta" + (let ((partial-default + (default-value 'truncate-partial-width-windows))) + (setq-local truncate-lines t) + (kill-local-variable 'truncate-partial-width-windows) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 80))) + (ekp-auto-justify-mode 1) + (unwind-protect + (progn + (should (local-variable-p 'truncate-lines)) + (should-not truncate-lines) + (should + (local-variable-p 'truncate-partial-width-windows)) + (should-not truncate-partial-width-windows)) + (ekp-auto-justify-mode -1))) + (should (local-variable-p 'truncate-lines)) + (should truncate-lines) + (should-not + (local-variable-p 'truncate-partial-width-windows)) + (should + (equal truncate-partial-width-windows partial-default))))) + +(ert-deftest ekp-buffer-test-mode-enable-failure-restores-native-soft-wrap () + "A failed enable restores soft-wrap values, ownership, and mode state." + (ekp-buffer-test--with-text "alpha beta gamma delta" + (let ((partial-default + (default-value 'truncate-partial-width-windows))) + (setq-local truncate-lines t) + (kill-local-variable 'truncate-partial-width-windows) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 80)) + ((symbol-function 'ekp-buffer--reflow) + (lambda (&rest _) (error "forced reflow failure")))) + (should-error (ekp-auto-justify-mode 1) + :type 'error)) + (should-not ekp-auto-justify-mode) + (should-not ekp-buffer--wrap-state) + (should (local-variable-p 'truncate-lines)) + (should truncate-lines) + (should-not + (local-variable-p 'truncate-partial-width-windows)) + (should + (equal truncate-partial-width-windows partial-default))))) + +(ert-deftest ekp-buffer-test-mode-roundtrip () + "Enabling then disabling the mode restores the buffer exactly." + (let ((text "first paragraph 内容 aaa bbb ccc\nsecond paragraph 内容 ddd")) + (ekp-buffer-test--with-mode text 150 + (should ekp-buffer--auto-width) + (should (get-text-property (point-min) 'ekp-justified))) + ;; body ran; with-mode disabled the mode on exit — verify restore + (ekp-buffer-test--with-text text + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 150))) + (ekp-auto-justify-mode 1) + (ekp-auto-justify-mode -1) + (should (equal-including-properties (buffer-string) text)))))) + +(ert-deftest ekp-buffer-test-mode-incremental-edit () + "A same-line live edit preserves the other hard line byte-for-byte." + (let ((text "aaa bbb ccc ddd eee fff\nggg hhh iii jjj kkk lll") + (calls nil)) + (ekp-buffer-test--with-text text + (goto-char (point-min)) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 100))) + (ekp-auto-justify-mode 1) + (unwind-protect + (let* ((hard-nl (line-end-position)) + (second (buffer-substring (1+ hard-nl) (point-max))) + (orig (symbol-function 'ekp-justify-region))) + (cl-letf (((symbol-function 'ekp-justify-region) + (lambda (beg end &optional width) + (push (list beg end width) calls) + (funcall orig beg end width)))) + (goto-char (+ (point-min) 4)) + (insert "zz") + (should-not calls) + (should ekp-buffer--live-edit) + (should-not + (get-text-property (point-min) 'ekp-justified)) + (should (equal-including-properties + (buffer-substring + (+ hard-nl 3) (point-max)) + second)))) + (ekp-auto-justify-mode -1))) + (should (equal (buffer-string) + "aaa zzbbb ccc ddd eee fff\nggg hhh iii jjj kkk lll"))))) + +(ert-deftest ekp-buffer-test-verbatim-paragraph-skipped () + "A code-block paragraph stays byte-identical; prose around it justifies." + (let* ((code (propertize "(defun foo (x) (list 1 2))" + 'ekp-verbatim t 'face 'font-lock-keyword-face)) + (text (concat "prose before with words enough to wrap lines\n" + code + "\nprose after also long enough to wrap lines"))) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 20) + ;; the code line is still there, character-exact, spacing intact + (goto-char (point-min)) + (should (search-forward "(defun foo (x) (list 1 2))" nil t)) + ;; prose received a projection while the code paragraph did not + (should (get-text-property (point-min) 'ekp-justified)) + (goto-char (point-min)) + (search-forward "(defun") + (should-not (get-text-property (match-beginning 0) 'ekp-justified)) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal-including-properties (buffer-string) text))))) + +(ert-deftest ekp-buffer-test-skip-faces () + "Paragraphs wearing a skip face stay verbatim." + (let* ((ekp-buffer-skip-faces '(font-lock-comment-face)) + (code (propertize ";; a comment line kept as-is" + 'face 'font-lock-comment-face)) + (text (concat "prose paragraph long enough to wrap\n" code))) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 15) + (goto-char (point-min)) + (should (search-forward ";; a comment line kept as-is" nil t)) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal-including-properties (buffer-string) text))))) + +(ert-deftest ekp-buffer-test-skip-predicate () + "The paragraph predicate is the general escape hatch." + (let ((text "keepme raw spacing\nnormal prose that wraps around")) + (ekp-buffer-test--with-text text + (setq-local ekp-buffer-skip-predicate + (lambda (p) (string-prefix-p "keepme" p))) + (ekp-justify-region (point-min) (point-max) 12) + (goto-char (point-min)) + (should (looking-at-p "keepme raw spacing$")) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal (buffer-string) text))))) + +(ert-deftest ekp-buffer-test-indent-roundtrip () + "First-line indent spacers vanish exactly on unjustify." + (let ((ekp-first-line-indent 6) + (text "首行缩进往返检查内容足够长断行几次")) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 30) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal-including-properties (buffer-string) text))))) + +(ert-deftest ekp-buffer-test-resize-hook-window-arg () + "The resize hook handles its WINDOW argument and foreign current buffer. +Regression: buffer-local `window-size-change-functions' members get +the displaying WINDOW, with an arbitrary buffer current." + (let ((text "resize hook 检查 aaa bbb ccc ddd eee fff")) + (ekp-buffer-test--with-mode text 200 + (let ((buf (current-buffer)) + (win (selected-window))) + (set-window-buffer win buf) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 120))) + ;; simulate redisplay: window argument, unrelated buffer current + (with-temp-buffer + (ekp-buffer--on-resize win))) + (with-current-buffer buf + (should (timerp ekp-buffer--resize-timer)) + (cancel-timer ekp-buffer--resize-timer) + ;; run what the timer would have run + (ekp-buffer--reflow buf 120) + (should (= ekp-buffer--auto-width 120))))))) + +(ert-deftest ekp-buffer-test-mode-reflow-width () + "Reflow updates completed paragraphs and commits the active paragraph." + (let ((text + (concat "reflow 检查 aaa bbb ccc ddd eee fff ggg hhh\n" + "active tail remains natural while editing")) + fresh) + (ekp-buffer-test--with-text + "reflow 检查 aaa bbb ccc ddd eee fff ggg hhh" + (ekp-justify-region (point-min) (point-max) 90) + (setq fresh (buffer-substring (point-min) (point-max)))) + (ekp-buffer-test--with-mode text 200 + (ekp-buffer--reflow (current-buffer) 90) + (should (= ekp-buffer--auto-width 90)) + (let* ((newline (save-excursion + (goto-char (point-min)) + (search-forward "\n") + (1- (point)))) + (got (buffer-substring (point-min) newline))) + ;; ekp-justified was written at two widths; ignore that prop + (remove-text-properties 0 (length got) '(ekp-justified nil) got) + (remove-text-properties 0 (length fresh) '(ekp-justified nil) fresh) + (should (equal-including-properties got fresh)) + (should-not + (text-property-not-all + (1+ newline) (point-max) 'ekp-justified 90)))))) + + +(ert-deftest ekp-buffer-test-lazy-reflow-equals-oneshot () + "Visible-first chunked re-flow converges to the one-shot result." + (let ((ekp-auto-justify-lazy-threshold 100) + (ekp-auto-justify-chunk-size 3) + (text (mapconcat #'identity + (make-list 12 "段落内容 some words 足够长会换行的样子") + "\n"))) + (ekp-buffer-test--with-text text + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 60)) + ((symbol-function 'ekp-buffer--visible-span) + (lambda () (cons (point-min) (min (point-max) 80))))) + (ekp-auto-justify-mode 1) + (ekp-buffer--reflow (current-buffer) 50) + (should ekp-buffer--pending) + ;; drain the background queue synchronously + (let ((guard 0)) + (while (and ekp-buffer--pending (< guard 100)) + (when (timerp ekp-buffer--chunk-timer) + (cancel-timer ekp-buffer--chunk-timer) + (setq ekp-buffer--chunk-timer nil)) + (ekp-buffer--process-chunk (current-buffer)) + (setq guard (1+ guard)))) + (should-not ekp-buffer--pending) + (let ((lazy (buffer-string))) + (ekp-auto-justify-mode -1) + (ekp-justify-region (point-min) (point-max) 50) + (should (equal-including-properties (buffer-string) lazy))))))) + +(ert-deftest ekp-buffer-test-lazy-reflow-preserves-active-live-spans () + "Pending lazy chunks never detach spans owned by the active paragraph." + (let ((ekp-auto-justify-lazy-threshold 1) + (ekp-auto-justify-chunk-size 1) + (text + (mapconcat + (lambda (index) + (format + "para%02d alpha beta gamma delta epsilon zeta eta theta" + index)) + (number-sequence 1 8) + "\n"))) + (ekp-buffer-test--with-text text + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 20)) + ((symbol-function 'ekp-buffer--visible-span) + (lambda () + (cons (point-min) + (save-excursion + (goto-char (point-min)) + (line-end-position)))))) + (ekp-auto-justify-mode 1) + (unwind-protect + (progn + (goto-char (point-max)) + (insert " x") + (ekp-buffer--reflow (current-buffer) 16) + (while (cdr ekp-buffer--pending) + (ekp-buffer--process-one-chunk)) + (should-not + (seq-find + (lambda (span) + (not (marker-buffer (ekp-buffer--span-beg span)))) + (ekp-buffer--live-state-spans ekp-buffer--live-state))) + (goto-char (point-max)) + (insert " y")) + (ekp-auto-justify-mode -1)))))) + +;;;; Editor-state integrity (save / modified / undo / stickiness) + +(ert-deftest ekp-buffer-test-typed-char-inherits-no-marker () + "Text typed beside a projection never inherits stale layout properties." + (ekp-buffer-test--with-text "aaa bbb 中文 ccc" + (ekp-justify-region (point-min) (point-max) 200) + (let ((display-pos (text-property-not-all + (point-min) (point-max) + 'ekp-buffer--display nil))) + (should display-pos) + (goto-char (1+ display-pos)) + (insert-and-inherit "X") + (let ((x (1+ display-pos))) + (should-not (get-text-property x 'ekp-justified)) + (should-not (get-text-property x 'ekp-buffer--display)) + (should-not (get-text-property x 'display)) + (should-not (get-text-property x 'line-prefix)))) + (ekp-unjustify-region (point-min) (point-max)) + (should (= 1 (cl-count ?X (buffer-string)))))) + +(ert-deftest ekp-buffer-test-save-writes-logical-text () + "Saving a justified file buffer writes the logical text to disk, +keeps the buffer justified, and leaves it unmodified." + (let* ((file (make-temp-file "ekp-save-test")) + (text "中文保存测试内容足够长会断行的样子,再加一句凑长度。") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (should (text-property-not-all + (point-min) (point-max) + 'ekp-buffer--display nil)) + (should (= (cl-count ?\n (buffer-string)) 0)) + (save-buffer) + ;; Disk: logical text only, no layout newlines. + (should (equal (with-temp-buffer + (insert-file-contents file) + (buffer-string)) + text)) + ;; Buffer: still justified, and not "modified" vs its file. + (should (get-text-property (point-min) 'ekp-justified)) + (should-not (buffer-modified-p)) + ;; And a second save still works (state was reset). + (insert "x") + (goto-char (point-min)) + (save-buffer) + (should (equal (with-temp-buffer + (insert-file-contents file) + (buffer-string)) + (concat text "x"))) + (let ((kill-buffer-query-functions nil)) + (kill-buffer))) + (delete-file file)))) + +(ert-deftest ekp-buffer-test-failed-save-preserves-layout () + "A filesystem save failure must not leave the buffer unformatted." + (let* ((dir (make-temp-file "ekp-save-fail-" t)) + (file (expand-file-name "file.txt" dir)) + (text "保存失败以后屏幕仍然保持排版状态 and remains editable") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (setq-local require-final-newline nil) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (let ((layout (buffer-substring (point-min) (point-max)))) + (delete-directory dir t) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (&rest _) nil))) + (should-error (save-buffer))) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (get-text-property (point-min) 'ekp-justified)) + (should (buffer-modified-p))) + (let ((kill-buffer-query-functions nil)) + (set-buffer-modified-p nil) + (kill-buffer))) + (when (file-directory-p dir) + (delete-directory dir t))))) + +(ert-deftest ekp-buffer-test-interrupted-save-preserves-layout () + "A quit during writing must not leave the buffer unformatted." + (let* ((file (make-temp-file "ekp-save-quit-")) + (text "保存中断以后屏幕排版状态必须原样保留 with logical text") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (setq-local require-final-newline nil) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (let ((layout (buffer-substring (point-min) (point-max)))) + (cl-letf (((symbol-function 'write-region) + (lambda (&rest _) (signal 'quit nil)))) + (should (condition-case nil + (progn (save-buffer) nil) + (quit t)))) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (buffer-modified-p))) + (let ((kill-buffer-query-functions nil)) + (set-buffer-modified-p nil) + (kill-buffer))) + (delete-file file)))) + +(ert-deftest ekp-buffer-test-encoding-save-failure-is-retryable () + "An encoding failure must preserve layout and allow a clean retry." + (let* ((file (make-temp-file "ekp-save-encoding-")) + (text "编码失败以后仍然保持排版,重试写入 logical text") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (setq-local require-final-newline nil) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (let ((layout (buffer-substring (point-min) (point-max)))) + (set-buffer-file-coding-system 'us-ascii-unix) + (cl-letf (((symbol-function 'select-safe-coding-system) + (lambda (&rest _) (error "Forced encoding failure")))) + (should-error (save-buffer))) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (buffer-modified-p)) + (set-buffer-file-coding-system 'utf-8-unix) + (save-buffer) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (equal (with-temp-buffer + (insert-file-contents file) + (buffer-string)) + text))) + (let ((kill-buffer-query-functions nil)) + (kill-buffer))) + (delete-file file)))) + +(ert-deftest ekp-buffer-test-justify-preserves-unmodified () + "Pure re-layout must not flip `buffer-modified-p'." + (let* ((file (make-temp-file "ekp-mod-test")) + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (insert "modified 标志保持检查内容足够长断行") + (save-buffer) + (should-not (buffer-modified-p)) + (ekp-justify-region (point-min) (point-max) 30) + (should-not (buffer-modified-p)) + (ekp-unjustify-region (point-min) (point-max)) + (should-not (buffer-modified-p)) + ;; A real edit still marks the buffer modified. + (insert "y") + (should (buffer-modified-p)) + (let ((kill-buffer-query-functions nil)) + (set-buffer-modified-p nil) + (kill-buffer))) + (delete-file file)))) + +(ert-deftest ekp-buffer-test-live-undo-restores-without-pending-layout () + "Real undo restores logical text and finishes its live transaction." + (let ((text "undo guard 检查内容 aaa bbb ccc")) + (ekp-buffer-test--with-mode text 100 + (buffer-enable-undo) + (undo-boundary) + (goto-char (point-max)) + (insert " x") + (undo-boundary) + (undo-only 1) + (should (equal (substring-no-properties (buffer-string)) text)) + (should-not ekp-buffer--live-edit) + (should-not (timerp ekp-buffer--composition-timer)) + (should-not (overlays-in (point-min) (point-max)))))) + +(ert-deftest ekp-buffer-test-live-kill-region-reflows-through-public-path () + "A real `kill-region' keeps live layout and copies no projection." + (let ((text "alpha beta gamma delta epsilon zeta") + (kill-ring nil) + kill-ring-yank-pointer) + (ekp-buffer-test--with-mode text 16 + (goto-char (point-min)) + (search-forward "gamma ") + (kill-region (match-beginning 0) (match-end 0)) + (should (equal (substring-no-properties (buffer-string)) + "alpha beta delta epsilon zeta")) + (should (equal (current-kill 0 t) "gamma ")) + (should-not + (text-property-not-all + 0 (length (current-kill 0 t)) + 'ekp-buffer--display nil (current-kill 0 t))) + (should ekp-buffer--live-state) + (should ekp-buffer--live-edit) + (ekp-buffer-test--assert-natural-range + (marker-position + (ekp-buffer--live-edit-dirty-beg ekp-buffer--live-edit)) + (marker-position + (ekp-buffer--live-edit-dirty-end ekp-buffer--live-edit)))))) + +(ert-deftest ekp-buffer-test-live-newline-keeps-new-point-line-natural () + "A user hard break commits neighbors but keeps the new point line active." + (let ((text "alpha beta gamma delta epsilon zeta")) + (ekp-buffer-test--with-mode text 16 + (goto-char (point-min)) + (search-forward "gamma ") + (insert "\n") + (should (get-text-property (point-min) 'ekp-justified)) + (should ekp-buffer--live-state) + (should (ekp-buffer--live-state-contains-p (point))) + (should-not (get-text-property (point) 'ekp-justified)) + (should (equal (substring-no-properties (buffer-string)) + "alpha beta gamma \ndelta epsilon zeta")) + (should-not ekp-buffer--live-edit)))) + +(ert-deftest ekp-buffer-test-major-mode-change-restores () + "Switching major mode tears the justified state down cleanly." + (let ((text "major mode 切换检查 aaa bbb ccc ddd")) + (ekp-buffer-test--with-text text + (let ((partial-default + (default-value 'truncate-partial-width-windows)) + observed-wrap-state) + (setq-local truncate-lines t) + (kill-local-variable 'truncate-partial-width-windows) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 80))) + (ekp-auto-justify-mode 1) + (should ekp-buffer--live-state) + (add-hook + 'change-major-mode-hook + (lambda () + (setq observed-wrap-state + (list (local-variable-p 'truncate-lines) + truncate-lines + (local-variable-p + 'truncate-partial-width-windows) + truncate-partial-width-windows))) + t t) + (fundamental-mode) + (should (equal observed-wrap-state + (list t t nil partial-default))) + (should (equal (buffer-string) text)) + (should-not ekp-auto-justify-mode) + (should (local-variable-p 'truncate-lines)) + (should truncate-lines) + (should-not + (local-variable-p 'truncate-partial-width-windows))))))) + +;;;; Ecosystem compatibility (kill ring / isearch / fields / read-only) + +(ert-deftest ekp-buffer-test-kill-ring-gets-logical-text () + "Copying projected text strips EKP properties from logical characters." + (let ((text "中文复制检查内容足够长会断行 with some latin")) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 20) + (should (local-variable-p 'filter-buffer-substring-function)) + (should (equal (filter-buffer-substring (point-min) (point-max)) + text))))) + +(ert-deftest ekp-buffer-test-copy-filter-composes-and-restores () + "EKP must preserve an existing buffer-local substring filter." + (let ((text "组合复制过滤器必须保留 logical text and prefix")) + (ekp-buffer-test--with-text text + (let ((prior (lambda (beg end &optional delete) + (let ((text (buffer-substring beg end))) + (when delete (delete-region beg end)) + (concat "PRE:" text))))) + (setq-local filter-buffer-substring-function prior) + (ekp-justify-region (point-min) (point-max) 20) + (should (equal (filter-buffer-substring (point-min) (point-max)) + (concat "PRE:" text))) + (ekp-unjustify-region (point-min) (point-max)) + (should (local-variable-p 'filter-buffer-substring-function)) + (should (eq filter-buffer-substring-function prior)))))) + +(ert-deftest ekp-buffer-test-kill-filter-composes-delete () + "Composed filtering must preserve DELETE and prior-filter semantics." + (let ((text "组合 kill 过滤器删除源文本但返回 logical text")) + (ekp-buffer-test--with-text text + (let ((prior (lambda (beg end &optional delete) + (let ((text (buffer-substring beg end))) + (when delete (delete-region beg end)) + (concat "PRE:" text))))) + (setq-local filter-buffer-substring-function prior) + (ekp-justify-region (point-min) (point-max) 20) + (should (equal (filter-buffer-substring + (point-min) (point-max) t) + (concat "PRE:" text))) + (should (= (point-min) (point-max))) + (should (eq filter-buffer-substring-function prior)))))) + +(ert-deftest ekp-buffer-test-restores-inherited-copy-filter () + "Final unjustify must reveal an inherited substring filter again." + (let ((prior (lambda (beg end &optional delete) + (prog1 (buffer-substring beg end) + (when delete (delete-region beg end)))))) + (let ((filter-buffer-substring-function prior)) + (ekp-buffer-test--with-text "继承 filter 恢复检查内容" + (ekp-justify-region (point-min) (point-max) 20) + (ekp-unjustify-region (point-min) (point-max)) + (should-not (local-variable-p 'filter-buffer-substring-function)) + (should (eq filter-buffer-substring-function prior)))))) + +(ert-deftest ekp-buffer-test-mode-disable-restores-copy-filter () + "Disabling auto mode must restore the previous local copy filter." + (let ((prior (lambda (beg end &optional delete) + (prog1 (buffer-substring beg end) + (when delete (delete-region beg end)))))) + (ekp-buffer-test--with-text "关闭 mode 恢复已有 copy filter" + (setq-local filter-buffer-substring-function prior) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 20))) + (ekp-auto-justify-mode 1) + (goto-char (point-max)) + (insert " x") + (let ((prefix-end + (ekp-buffer--live-state-prefix-end + ekp-buffer--live-state))) + (should (markerp prefix-end)) + (ekp-auto-justify-mode -1) + (should-not (marker-buffer prefix-end)))) + (should-not ekp-buffer--live-state) + (should (local-variable-p 'filter-buffer-substring-function)) + (should (eq filter-buffer-substring-function prior))))) + +(ert-deftest ekp-buffer-test-final-unjustify-removes-integrations () + "Removing the final layout span must remove unused integrations." + (ekp-buffer-test--with-text "最后一个排版区间移除后清理集成 hooks" + (ekp-justify-region (point-min) (point-max) 20) + (should (memq #'ekp-buffer--before-change + before-change-functions)) + (should (local-variable-p 'filter-buffer-substring-function)) + (ekp-unjustify-region (point-min) (point-max)) + (should-not (local-variable-p 'filter-buffer-substring-function)) + (should-not (memq #'ekp-buffer--before-change + before-change-functions)))) + +(ert-deftest ekp-buffer-test-isearch-sees-logical-text () + "Search sees logical text directly without suspending the projection." + (let ((text "跨行搜索的目标短语必须能找到 internationalization word")) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 20) + (let ((justified (buffer-string))) + (should (= (cl-count ?\n justified) 0)) + (goto-char (point-min)) + (should (search-forward "目标短语必须能找到" nil t)) + (goto-char (point-min)) + (should (search-forward "internationalization" nil t)) + (should (equal-including-properties (buffer-string) justified)))))) + +(ert-deftest ekp-buffer-test-field-paragraph-skipped () + "Paragraphs containing field or read-only text stay verbatim." + (let* ((prompt (propertize "shell> " 'field 'output)) + (text (concat prompt "command output here\n" + "prose paragraph long enough to wrap around"))) + (ekp-buffer-test--with-text text + (ekp-justify-region (point-min) (point-max) 15) + (goto-char (point-min)) + (should (search-forward "command output here" nil t))))) + +(ert-deftest ekp-buffer-test-read-only-command-barfs () + "Interactive justify on a read-only buffer signals, not corrupts." + (ekp-buffer-test--with-text "read only 检查内容" + (set-mark (point-min)) + (goto-char (point-max)) + (activate-mark) + (read-only-mode 1) + (should-error (call-interactively #'ekp-justify-region) + :type 'buffer-read-only))) + +;;;; Commands and mode integration + +(ert-deftest ekp-buffer-test-no-break-public-commands () + "Interactive no-break commands affect the public formatter and report scope." + (ekp-buffer-test--with-text "prefix AA BB suffix words" + (let (messages) + (set-mark 8) + (goto-char 13) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-no-break-region)) + (should (eq (get-text-property 8 'ekp-no-break) t)) + (ekp-justify-region (point-min) (point-max) 4) + (should (string-match-p "AA BB" (buffer-string))) + (ekp-unjustify-region (point-min) (point-max)) + (set-mark 8) + (goto-char 13) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-allow-break-region)) + (should-not (get-text-property 8 'ekp-no-break)) + (should (= (length messages) 2)) + (should (cl-every + (lambda (text) + (string-match-p "current buffer session" text)) + messages))))) + +(ert-deftest ekp-buffer-test-verbatim-public-commands () + "Interactive verbatim commands protect the real region formatter." + (ekp-buffer-test--with-text + "literal block stays exactly here\nordinary prose wraps here" + (goto-char (point-min)) + (let ((first-end (line-end-position)) messages) + (set-mark (point-min)) + (goto-char first-end) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-verbatim-region)) + (ekp-justify-region (point-min) (point-max) 8) + (should (equal (buffer-substring-no-properties + (point-min) (line-end-position)) + "literal block stays exactly here")) + (ekp-unjustify-region (point-min) (point-max)) + (set-mark (point-min)) + (goto-char first-end) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-clear-verbatim-region)) + (ekp-justify-region (point-min) first-end 8) + (should (get-text-property (point-min) 'ekp-justified)) + (should (= (length messages) 2)) + (should (cl-every + (lambda (text) + (string-match-p "current buffer session" text)) + messages))))) + +(ert-deftest ekp-buffer-test-protection-workflows-discoverable () + "Mode help and menu expose the existing protection workflows." + (should (string-match-p + "current buffer[[:space:]]+session" + (documentation #'ekp-auto-justify-mode))) + (let ((menu (lookup-key ekp-auto-justify-mode-map [menu-bar ekp]))) + (should (keymapp menu)) + (should (where-is-internal + #'ekp-no-break-region ekp-auto-justify-mode-map)) + (should (where-is-internal + #'ekp-verbatim-region ekp-auto-justify-mode-map)))) + +(ert-deftest ekp-buffer-test-justify-buffer-roundtrip () + "ekp-justify-buffer / ekp-unjustify-buffer cover the whole buffer." + (let ((text "第一段内容足够长断行\n\n第二段 also long enough to wrap")) + (ekp-buffer-test--with-text text + (ekp-justify-buffer 25) + (should (get-text-property (point-min) 'ekp-justified)) + (ekp-unjustify-buffer) + (should (equal-including-properties (buffer-string) text))))) + +(ert-deftest ekp-buffer-test-justify-dwim-paragraph () + "Without an active region, the commands act on the paragraph at point." + (ekp-buffer-test--with-text + "para one short\npara two 目标段落内容足够长会断行几次\npara three" + (goto-char (point-min)) + (search-forward "目标") + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 20))) + (call-interactively #'ekp-justify-region)) + ;; Only paragraph two is justified. + (goto-char (point-min)) + (should-not (get-text-property (point) 'ekp-justified)) + (search-forward "目标") + (should (get-text-property (match-beginning 0) 'ekp-justified)) + (goto-char (point-max)) + (should-not (get-text-property (1- (point)) 'ekp-justified)) + ;; And unjustify DWIM restores just as well. + (goto-char (point-min)) + (search-forward "目标") + (call-interactively #'ekp-unjustify-region) + (should (equal (buffer-string) + "para one short\npara two 目标段落内容足够长会断行几次\npara three")))) + +(ert-deftest ekp-buffer-test-refill-paragraph () + "`ekp-refill-paragraph' re-justifies the paragraph at point." + (ekp-buffer-test--with-text "refill 检查内容足够长会断行几次的样子\nsecond para" + (goto-char (point-min)) + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 20))) + (ekp-refill-paragraph)) + (should (get-text-property (point-min) 'ekp-justified)) + (goto-char (point-max)) + (should-not (get-text-property (1- (point)) 'ekp-justified)))) + +(ert-deftest ekp-buffer-test-markdown-setup () + "Markdown setup preserves the mode's display-property ownership." + (with-temp-buffer + (setq-local font-lock-extra-managed-props '(display composition)) + (ekp-markdown-setup) + (should (equal font-lock-extra-managed-props + '(display composition))) + (should (equal ekp-buffer-skip-faces ekp-buffer-markdown-skip-faces)))) + +(ert-deftest ekp-buffer-test-org-auto-preset () + "Enabling the mode in an Org buffer applies the Org skip preset." + (with-temp-buffer + (org-mode) + (insert "普通正文段落内容足够长断行几次的样子") + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 100))) + (ekp-auto-justify-mode 1) + (unwind-protect + (should (equal ekp-buffer-skip-faces ekp-buffer-org-skip-faces)) + (ekp-auto-justify-mode -1))))) + +;;;; Lazy re-flow scheduling + +(ert-deftest ekp-buffer-test-live-unsupported-gap-stays-verbatim () + "Live layout stops instead of pretending to shrink non-ASCII whitespace." + (let ((text "aa\tbb cc dd") + (original (symbol-function 'ekp--measured-width))) + (cl-letf (((symbol-function 'ekp--measured-width) + (lambda (string) + (if (equal (substring-no-properties string) "\t") + 5 + (funcall original string))))) + (ekp-buffer-test--with-mode text 100 + (setq ekp-buffer--conflicts nil) + (goto-char (point-max)) + (insert " x") + (ekp-buffer--commit-live-paragraph) + (should-not ekp-buffer--spans) + (should-not (text-property-not-all + (point-min) (point-max) 'ekp-justified nil)) + (should + (string-match-p + "unsupported whitespace shrink" + (caddr (car ekp-buffer--conflicts)))) + (should (equal (substring-no-properties (buffer-string)) + (concat text " x"))))))) + +(ert-deftest ekp-buffer-test-live-oversized-paragraph-never-blocks-on-planning () + "Automatic layout leaves an oversized hard paragraph natural." + (let ((ekp-auto-justify-paragraph-limit 20) + (text (make-string 60 ?a)) + calls) + (ekp-buffer-test--with-text text + (cl-letf (((symbol-function 'ekp-buffer--window-pixel) + (lambda (&optional _) 12)) + ((symbol-function 'ekp-layout-plan) + (lambda (&rest _) + (setq calls (1+ (or calls 0))) + (error "oversized paragraph reached the planner")))) + (ekp-auto-justify-mode 1) + (unwind-protect + (progn + (goto-char (point-max)) + (insert "x") + (should-not calls) + (should-not ekp-buffer--spans) + (should-not (text-property-not-all + (point-min) (point-max) + 'ekp-justified nil)) + (should + (string-match-p + "automatic paragraph limit" + (caddr (car ekp-buffer--conflicts)))) + (should (equal (substring-no-properties (buffer-string)) + (concat text "x")))) + (ekp-auto-justify-mode -1)))))) + +(ert-deftest ekp-buffer-test-prioritize-visible-chunks () + "Chunks intersecting the visible span move to the queue front." + (ekp-buffer-test--with-text "abc" + (setq ekp-buffer--auto-width 100) + (let* ((mk (lambda (a b) (cons (copy-marker a) (copy-marker b)))) + (c1 (funcall mk 1 2)) + (c2 (funcall mk 2 3)) + (c3 (funcall mk 3 4))) + (setq ekp-buffer--pending (cons 100 (list c1 c2 c3))) + (cl-letf (((symbol-function 'ekp-buffer--visible-span) + (lambda () (cons 3 4)))) + (ekp-buffer--prioritize-visible)) + (should (eq (cadr ekp-buffer--pending) c3)) + (ekp-buffer--cancel-pending)))) + +(ert-deftest ekp-buffer-test-tick-budget-batches-chunks () + "A generous tick budget drains several chunks in one tick; +a zero budget still makes progress (exactly one chunk)." + (let ((text (mapconcat #'identity + (make-list 6 "分块预算检查内容足够长") + "\n"))) + (ekp-buffer-test--with-text text + (setq ekp-buffer--auto-width 40) + (setq ekp-buffer--pending + (cons 40 (ekp-buffer--make-chunks (point-min) (point-max)))) + (setq-local ekp-auto-justify-chunk-size 1) + ;; zero budget: one chunk per tick + (let ((ekp-auto-justify-tick-budget 0) + (before (length (cdr ekp-buffer--pending)))) + (cl-letf (((symbol-function 'input-pending-p) #'ignore)) + (let ((ekp-auto-justify-mode t)) + (ekp-buffer--process-chunk (current-buffer)))) + (should (= (length (cdr ekp-buffer--pending)) (1- before)))) + (when (timerp ekp-buffer--chunk-timer) + (cancel-timer ekp-buffer--chunk-timer) + (setq ekp-buffer--chunk-timer nil)) + ;; big budget: the rest drains in one tick + (let ((ekp-auto-justify-tick-budget 10.0)) + (cl-letf (((symbol-function 'input-pending-p) #'ignore)) + (let ((ekp-auto-justify-mode t)) + (ekp-buffer--process-chunk (current-buffer))))) + (should-not ekp-buffer--pending)))) + +(provide 'ekp-buffer-tests) + +;;; ekp-buffer-tests.el ends here diff --git a/tests/ekp-c-resize-evaluator.el b/tests/ekp-c-resize-evaluator.el new file mode 100644 index 0000000..2775073 --- /dev/null +++ b/tests/ekp-c-resize-evaluator.el @@ -0,0 +1,344 @@ +;;; ekp-c-resize-evaluator.el --- C resize performance gate -*- lexical-binding: t; -*- + +;;; Commentary: + +;; One process measures one frozen-baseline or candidate round. Compare mode +;; aggregates the interleaved JSONL records and enforces the task032 contract. + +;;; Code: + +(require 'cl-lib) +(require 'json) + +(defvar ekp-use-c-module) +(defvar ekp-auto-justify-mode) +(defvar ekp-auto-justify-lazy-threshold) +(defvar ekp-buffer--auto-width) +(declare-function ekp-showcase--sample "ekp-showcase") +(declare-function ekp-buffer--paragraph-ranges "ekp-buffer") +(declare-function ekp-buffer--skip-paragraph-p "ekp-buffer") +(declare-function ekp-buffer--reflow "ekp-buffer") +(declare-function ekp--get-para "ekp") +(declare-function ekp-para-dp-cache "ekp") +(declare-function ekp-justify-region "ekp-buffer") +(declare-function ekp-clear-caches "ekp") +(declare-function ekp-c-module-load "ekp-utils") +(declare-function ekp--c-available-p "ekp") +(declare-function ekp-c-version "ext:ekp") + +(defconst ekp-c-resize-evaluator--widths + '(480 452 424 396 368 340 312 284 256 228 200 172)) + +(defconst ekp-c-resize-evaluator--warm-width 520) +(defconst ekp-c-resize-evaluator--target-ms 50.0) +(defconst ekp-c-resize-evaluator--minimum-improvement 20.0) + +(defvar ekp-c-resize-evaluator--plan-ms 0.0) +(defvar ekp-c-resize-evaluator--module-ms 0.0) +(defvar ekp-c-resize-evaluator--install-ms 0.0) +(defvar ekp-c-resize-evaluator--clear-ms 0.0) + +(defun ekp-c-resize-evaluator--fixtures () + "Return the fixed length matrix used by the evaluator." + (let ((sample (ekp-showcase--sample))) + `(("showcase" . ,sample) + ("showcase-2x" . ,(concat sample "\n\n" sample))))) + +(defun ekp-c-resize-evaluator--timed-call (counter function arguments) + "Call FUNCTION with ARGUMENTS and add elapsed milliseconds to COUNTER." + (let ((start (float-time))) + (prog1 (apply function arguments) + (set counter + (+ (symbol-value counter) + (* 1000.0 (- (float-time) start))))))) + +(defun ekp-c-resize-evaluator--reset-profile () + "Reset per-reflow layer counters." + (setq ekp-c-resize-evaluator--plan-ms 0.0 + ekp-c-resize-evaluator--module-ms 0.0 + ekp-c-resize-evaluator--install-ms 0.0 + ekp-c-resize-evaluator--clear-ms 0.0)) + +(defun ekp-c-resize-evaluator--layout-hash () + "Return a stable hash of the complete source and projection." + (secure-hash + 'sha256 + (prin1-to-string (buffer-substring (point-min) (point-max))))) + +(defun ekp-c-resize-evaluator--paragraphs () + "Return cached paragraph objects for the current logical buffer." + (cl-loop for range in + (ekp-buffer--paragraph-ranges (point-min) (point-max)) + for text = (buffer-substring (car range) (cdr range)) + unless (ekp-buffer--skip-paragraph-p text) + collect (ekp--get-para text))) + +(defun ekp-c-resize-evaluator--clear-dp (paragraphs) + "Clear only width-dependent DP entries in PARAGRAPHS." + (dolist (paragraph paragraphs) + (clrhash (ekp-para-dp-cache paragraph)))) + +(defun ekp-c-resize-evaluator--profiled-call (function arguments) + "Call FUNCTION with ARGUMENTS and return total plus layer timings." + (let ((plan (symbol-function 'ekp-layout-plan)) + (module (symbol-function 'ekp-c-break-with-arrays)) + (install (symbol-function 'ekp-buffer--install-plan)) + (live-install (symbol-function 'ekp-buffer--install-live-prefix)) + (clear (symbol-function 'ekp-buffer--clear-projection)) + (start (float-time))) + (ekp-c-resize-evaluator--reset-profile) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (&rest args) + (ekp-c-resize-evaluator--timed-call + 'ekp-c-resize-evaluator--plan-ms plan args))) + ((symbol-function 'ekp-c-break-with-arrays) + (lambda (&rest args) + (ekp-c-resize-evaluator--timed-call + 'ekp-c-resize-evaluator--module-ms module args))) + ((symbol-function 'ekp-buffer--install-plan) + (lambda (&rest args) + (ekp-c-resize-evaluator--timed-call + 'ekp-c-resize-evaluator--install-ms install args))) + ((symbol-function 'ekp-buffer--install-live-prefix) + (lambda (&rest args) + (ekp-c-resize-evaluator--timed-call + 'ekp-c-resize-evaluator--install-ms live-install args))) + ((symbol-function 'ekp-buffer--clear-projection) + (lambda (&rest args) + (ekp-c-resize-evaluator--timed-call + 'ekp-c-resize-evaluator--clear-ms clear args)))) + (apply function arguments)) + `((total_ms . ,(* 1000.0 (- (float-time) start))) + (plan_ms . ,ekp-c-resize-evaluator--plan-ms) + (module_ms . ,ekp-c-resize-evaluator--module-ms) + (install_ms . ,ekp-c-resize-evaluator--install-ms) + (clear_ms . ,ekp-c-resize-evaluator--clear-ms)))) + +(defun ekp-c-resize-evaluator--measure-core-fixture (fixture) + "Measure every uncached core width for FIXTURE." + (with-temp-buffer + (insert (cdr fixture)) + (let ((paragraphs (ekp-c-resize-evaluator--paragraphs)) + samples) + (ekp-justify-region + (point-min) (point-max) ekp-c-resize-evaluator--warm-width) + (dolist (width ekp-c-resize-evaluator--widths) + (ekp-c-resize-evaluator--clear-dp paragraphs) + (let ((before gcs-done) + (sample + (ekp-c-resize-evaluator--profiled-call + (symbol-function 'ekp-justify-region) + (list (point-min) (point-max) width)))) + (unless (= before gcs-done) + (error "GC occurred inside resize sample")) + (push (append `((path . "core") + (fixture . ,(car fixture)) (width . ,width)) + sample) + samples))) + (nreverse samples)))) + +(defun ekp-c-resize-evaluator--measure-resize-fixture (fixture) + "Measure every uncached full resize width for FIXTURE." + (with-temp-buffer + (insert (cdr fixture)) + (let ((paragraphs (ekp-c-resize-evaluator--paragraphs)) + (ekp-auto-justify-mode t) + (ekp-auto-justify-lazy-threshold most-positive-fixnum) + samples) + (ekp-buffer--reflow + (current-buffer) ekp-c-resize-evaluator--warm-width) + (dolist (width ekp-c-resize-evaluator--widths) + (ekp-c-resize-evaluator--clear-dp paragraphs) + (let ((before gcs-done) + (sample + (ekp-c-resize-evaluator--profiled-call + (symbol-function 'ekp-buffer--reflow) + (list (current-buffer) width)))) + (unless (= before gcs-done) + (error "GC occurred inside resize sample")) + (push (append `((path . "resize") + (fixture . ,(car fixture)) (width . ,width)) + sample) + samples))) + (nreverse samples)))) + +(defun ekp-c-resize-evaluator--layout-core-fixture (fixture) + "Return core projection hashes for every width of FIXTURE." + (with-temp-buffer + (insert (cdr fixture)) + (cl-loop for width in ekp-c-resize-evaluator--widths + do (ekp-justify-region (point-min) (point-max) width) + collect `((path . "core") (fixture . ,(car fixture)) + (width . ,width) + (hash . ,(ekp-c-resize-evaluator--layout-hash)))))) + +(defun ekp-c-resize-evaluator--layout-resize-fixture (fixture) + "Return full resize projection hashes for every width of FIXTURE." + (with-temp-buffer + (insert (cdr fixture)) + (let ((ekp-auto-justify-mode t) + (ekp-auto-justify-lazy-threshold most-positive-fixnum)) + (cl-loop for width in ekp-c-resize-evaluator--widths + do (ekp-buffer--reflow (current-buffer) width) + collect `((path . "resize") (fixture . ,(car fixture)) + (width . ,width) + (hash . ,(ekp-c-resize-evaluator--layout-hash))))))) + +(defun ekp-c-resize-evaluator--layout-matrix (use-c) + "Return the fixed layout matrix with USE-C selecting the engine." + (let ((ekp-use-c-module use-c)) + (ekp-clear-caches) + (let ((core + (cl-mapcan #'ekp-c-resize-evaluator--layout-core-fixture + (ekp-c-resize-evaluator--fixtures)))) + (ekp-clear-caches) + (append + core + (cl-mapcan #'ekp-c-resize-evaluator--layout-resize-fixture + (ekp-c-resize-evaluator--fixtures)))))) + +(defun ekp-c-resize-evaluator--measure-round () + "Measure one C round and return a JSON-compatible record." + (require 'ekp) + (require 'ekp-buffer) + (require 'ekp-showcase) + (ekp-c-module-load) + (unless (ekp--c-available-p) + (error "C module did not load")) + (let ((gc-cons-threshold most-positive-fixnum) + (label (or (getenv "EKP_RESIZE_LABEL") "unknown")) + (round (string-to-number (or (getenv "EKP_RESIZE_ROUND") "0")))) + (garbage-collect) + (ekp-clear-caches) + (let* ((core + (cl-mapcan #'ekp-c-resize-evaluator--measure-core-fixture + (ekp-c-resize-evaluator--fixtures))) + (_ (ekp-clear-caches)) + (resize + (cl-mapcan #'ekp-c-resize-evaluator--measure-resize-fixture + (ekp-c-resize-evaluator--fixtures))) + (samples (append core resize)) + (c-layouts (ekp-c-resize-evaluator--layout-matrix t)) + (elisp-layouts (ekp-c-resize-evaluator--layout-matrix nil))) + `((label . ,label) (round . ,round) + (module_version . ,(ekp-c-version)) + (c_elisp_parity . ,(equal c-layouts elisp-layouts)) + (layouts . ,c-layouts) (samples . ,samples))))) + +(defun ekp-c-resize-evaluator--write-record (record) + "Append JSON RECORD to `EKP_RESIZE_OUTPUT'." + (let ((path (getenv "EKP_RESIZE_OUTPUT"))) + (unless path (error "EKP_RESIZE_OUTPUT is required")) + (write-region (concat (json-encode record) "\n") nil path t 'silent))) + +(defun ekp-c-resize-evaluator--read-jsonl (path) + "Read JSON objects from PATH." + (with-temp-buffer + (insert-file-contents path) + (mapcar (lambda (line) + (json-parse-string line :object-type 'alist + :array-type 'list)) + (split-string (buffer-string) "\n" t)))) + +(defun ekp-c-resize-evaluator--sample-values (records path key) + "Return numeric KEY values from PATH samples in RECORDS." + (cl-loop for record in records + append (cl-loop for sample in (alist-get 'samples record) + when (equal (alist-get 'path sample) path) + collect (alist-get key sample)))) + +(defun ekp-c-resize-evaluator--percentile (values percentile) + "Return nearest-rank PERCENTILE from VALUES." + (let* ((sorted (sort (copy-sequence values) #'<)) + (rank (max 0 (1- (ceiling (* percentile (length sorted))))))) + (nth rank sorted))) + +(defun ekp-c-resize-evaluator--improvement (baseline candidate) + "Return percentage improvement from BASELINE to CANDIDATE." + (* 100.0 (/ (- baseline candidate) baseline))) + +(defun ekp-c-resize-evaluator--all-parity-p (records) + "Return non-nil when every RECORD matches its Elisp engine." + (cl-every (lambda (record) (eq (alist-get 'c_elisp_parity record) t)) + records)) + +(defun ekp-c-resize-evaluator--cross-parity-p (baseline candidate) + "Return non-nil when BASELINE and CANDIDATE layouts match exactly." + (and (equal (alist-get 'layouts (car baseline)) + (alist-get 'layouts (car candidate))) + (cl-every + (lambda (record) + (equal (alist-get 'layouts record) + (alist-get 'layouts (car baseline)))) + (append baseline candidate)))) + +(defun ekp-c-resize-evaluator--path-comparison (baseline candidate path) + "Return metrics for PATH in BASELINE and CANDIDATE records." + (let* ((base (ekp-c-resize-evaluator--sample-values + baseline path 'total_ms)) + (cand (ekp-c-resize-evaluator--sample-values + candidate path 'total_ms)) + (base-p50 (ekp-c-resize-evaluator--percentile base 0.50)) + (base-p95 (ekp-c-resize-evaluator--percentile base 0.95)) + (cand-p50 (ekp-c-resize-evaluator--percentile cand 0.50)) + (cand-p95 (ekp-c-resize-evaluator--percentile cand 0.95)) + (p50-gain (ekp-c-resize-evaluator--improvement base-p50 cand-p50)) + (p95-gain (ekp-c-resize-evaluator--improvement base-p95 cand-p95))) + `((baseline_p50_ms . ,base-p50) (baseline_p95_ms . ,base-p95) + (candidate_p50_ms . ,cand-p50) (candidate_p95_ms . ,cand-p95) + (p50_improvement_pct . ,p50-gain) + (p95_improvement_pct . ,p95-gain) + (candidate_target_pass + . ,(<= cand-p95 ekp-c-resize-evaluator--target-ms)) + (improvement_pass + . ,(and (>= p50-gain ekp-c-resize-evaluator--minimum-improvement) + (>= p95-gain ekp-c-resize-evaluator--minimum-improvement)))))) + +(defun ekp-c-resize-evaluator--comparison (baseline candidate) + "Return core and resize metrics for BASELINE and CANDIDATE records." + `((core . ,(ekp-c-resize-evaluator--path-comparison + baseline candidate "core")) + (resize . ,(ekp-c-resize-evaluator--path-comparison + baseline candidate "resize")))) + +(defun ekp-c-resize-evaluator--path-pass-p (metrics) + "Return non-nil when one path's METRICS pass both performance gates." + (and (eq (alist-get 'candidate_target_pass metrics) t) + (eq (alist-get 'improvement_pass metrics) t))) + +(defun ekp-c-resize-evaluator--report (baseline candidate) + "Return the final evaluator report for BASELINE and CANDIDATE." + (let* ((metrics (ekp-c-resize-evaluator--comparison baseline candidate)) + (parity (and (ekp-c-resize-evaluator--all-parity-p baseline) + (ekp-c-resize-evaluator--all-parity-p candidate) + (ekp-c-resize-evaluator--cross-parity-p + baseline candidate))) + (pass (and parity + (ekp-c-resize-evaluator--path-pass-p + (alist-get 'core metrics)) + (ekp-c-resize-evaluator--path-pass-p + (alist-get 'resize metrics))))) + `((pass . ,pass) (layout_parity . ,parity) + (core . ,(alist-get 'core metrics)) + (resize . ,(alist-get 'resize metrics))))) + +(defun ekp-c-resize-evaluator--compare () + "Compare raw JSONL paths from the environment and enforce the contract." + (let* ((baseline (ekp-c-resize-evaluator--read-jsonl + (getenv "EKP_RESIZE_BASELINE_JSONL"))) + (candidate (ekp-c-resize-evaluator--read-jsonl + (getenv "EKP_RESIZE_CANDIDATE_JSONL"))) + (report (ekp-c-resize-evaluator--report baseline candidate)) + (path (getenv "EKP_RESIZE_REPORT"))) + (when path + (write-region (concat (json-encode report) "\n") nil path nil 'silent)) + (princ (concat (json-encode report) "\n")) + (unless (eq (alist-get 'pass report) t) + (kill-emacs 1)))) + +(if (equal (getenv "EKP_RESIZE_MODE") "compare") + (ekp-c-resize-evaluator--compare) + (ekp-c-resize-evaluator--write-record + (ekp-c-resize-evaluator--measure-round))) + +;;; ekp-c-resize-evaluator.el ends here diff --git a/tests/ekp-c-tests.el b/tests/ekp-c-tests.el index 996396c..923e68f 100644 --- a/tests/ekp-c-tests.el +++ b/tests/ekp-c-tests.el @@ -11,6 +11,10 @@ (require 'cl-lib) (require 'ekp) +(declare-function ekp-c-break-batch "ext:ekp") +(declare-function ekp-c-break-with-arrays "ext:ekp") +(declare-function ekp-c-set-penalties "ext:ekp") + (defun ekp-c-tests--available () "Return non-nil when a compatible C module can be loaded." (or ekp-c-module-loaded diff --git a/tests/ekp-gui-verify.el b/tests/ekp-gui-verify.el index ea737fc..4390ed8 100644 --- a/tests/ekp-gui-verify.el +++ b/tests/ekp-gui-verify.el @@ -33,49 +33,78 @@ ;; you whether measurement matches rendering ;; under your fonts/remappings. ;; -;; emacs -Q -L . -L tests -l tests/ekp-gui-verify.el \ +;; ${EMACS} -Q -L . -L tests -l tests/ekp-gui-verify.el \ ;; -f ekp-gui-verify-matrix ;; runs the full matrix (plain, text-scale ;; up/down, face remap, narrow+scale) and ;; prints a PASS/FAIL table. ;; ;; Criterion: every justified line's rendered width equals the target -;; width (± `ekp-region-margin-pixel'). Verbatim paragraphs +;; width (± `ekp-buffer-margin-pixel'). Verbatim paragraphs ;; (`ekp-verbatim') are exempt — code blocks pass through unwrapped by ;; design and may exceed a narrow window, like any code line. ;;; Code: (require 'ekp) -(require 'ekp-region) +(require 'ekp-buffer) (require 'ekp-showcase) -(defun ekp-gui-verify--scan (buffer) - "Measure every line of BUFFER in its window; return a result plist." +(defun ekp-gui-verify--line-width (window base line) + "Measure planned LINE in WINDOW at paragraph BASE." + (car (window-text-pixel-size + window + (+ base (ekp-layout-line-source-start line)) + (+ base (ekp-layout-line-source-end line)) + t))) + +(defun ekp-gui-verify--scan-span (window span margin) + "Return measurement counters for SPAN in WINDOW using MARGIN." + (let* ((plan (ekp-buffer--span-plan span)) + (records (ekp-buffer--span-lines span)) + (target (ekp-buffer--span-width span)) + (widest 0) (over 0) (wrong 0)) + (dolist (record records) + (let* ((line (ekp-buffer--projected-line-line record)) + (base (marker-position + (ekp-buffer--projected-line-base record))) + (pixel (ekp-gui-verify--line-width window base line))) + (setq widest (max widest pixel)) + (when (> pixel (+ target margin)) + (setq over (1+ over))) + (when (and (> (length (ekp-layout-line-gaps line)) 0) + (ekp-layout-line-break-kind line) + (> (abs (- pixel target)) margin)) + (setq wrong (1+ wrong))))) + (list :widest widest :over over :wrong wrong + :lines (length records) + :static (and plan t)))) + +(defun ekp-gui-verify--scan (buffer &optional skip-predicate) + "Measure every projected display line of BUFFER. +When SKIP-PREDICATE is non-nil, omit spans for which it returns non-nil." (with-current-buffer buffer - (let* ((win (get-buffer-window buffer)) - (body (window-body-width win t)) - (target ekp-region--auto-width) - (worst 0) (over 0) (n 0) (exempt 0)) - (save-excursion - (goto-char (point-min)) - (while (not (eobp)) - (let* ((bol (line-beginning-position)) - (eol (line-end-position)) - (px (if (= bol eol) 0 - (car (window-text-pixel-size win bol eol t))))) - (when (> px 0) - (if (text-property-not-all bol eol 'ekp-verbatim nil) - (setq exempt (1+ exempt)) - (setq n (1+ n)) - (when (> px worst) (setq worst px)) - (when (> px body) (setq over (1+ over)))))) - (forward-line 1))) - (list :body body :target target :widest worst :over over - :lines n :exempt exempt - :pass (and (= over 0) - (<= (abs (- worst target)) - (max 2 ekp-region-margin-pixel))))))) + (let* ((window (get-buffer-window buffer)) + (body (window-body-width window t)) + (target ekp-buffer--auto-width) + (margin (max 2 ekp-buffer-margin-pixel)) + (widest 0) (over 0) (wrong 0) (lines 0)) + (dolist (span ekp-buffer--spans) + (unless (and skip-predicate (funcall skip-predicate span)) + (let ((result (ekp-gui-verify--scan-span window span margin))) + (setq widest (max widest (plist-get result :widest)) + over (+ over (plist-get result :over)) + wrong (+ wrong (plist-get result :wrong)) + lines (+ lines (plist-get result :lines)))))) + (list :body body :target target :widest widest :over over + :wrong wrong :lines lines :exempt 0 + :source-clean + (and (not (text-property-not-all + (point-min) (point-max) 'ekp-soft-break nil)) + (not (text-property-not-all + (point-min) (point-max) 'ekp-glue nil)) + (null (overlays-in (point-min) (point-max)))) + :pass (and (= over 0) (= wrong 0) (> lines 0)))))) ;;;###autoload (defun ekp-gui-verify () @@ -91,9 +120,9 @@ themes, remappings and text-scale. Reports PASS or FAIL." (redisplay t) (with-current-buffer "*ekp-showcase*" (ekp-auto-justify-mode 1) - (when (timerp ekp-region--resize-timer) - (cancel-timer ekp-region--resize-timer)) - (ekp-region--reflow (current-buffer) (ekp-region--effective-width)) + (when (timerp ekp-buffer--resize-timer) + (cancel-timer ekp-buffer--resize-timer)) + (ekp-buffer--reflow (current-buffer) (ekp-buffer--effective-width)) (redisplay t) (let* ((r (ekp-gui-verify--scan (current-buffer))) (msg (format @@ -112,9 +141,8 @@ themes, remappings and text-scale. Reports PASS or FAIL." "Run one matrix case NAME with buffer SETUP; return its result plist." ;; Leftover debounce timers from the previous case must not fire ;; into this case's fresh buffer. - (dolist (fn (list #'ekp-region--reflow - #'ekp-region--flush-dirty - #'ekp-region--process-chunk)) + (dolist (fn (list #'ekp-buffer--reflow + #'ekp-buffer--process-chunk)) (cancel-function-timers fn)) (when (get-buffer "*ekp-showcase*") (kill-buffer "*ekp-showcase*")) @@ -124,9 +152,9 @@ themes, remappings and text-scale. Reports PASS or FAIL." (funcall setup) (redisplay t) (ekp-auto-justify-mode 1) - (when (timerp ekp-region--resize-timer) - (cancel-timer ekp-region--resize-timer)) - (ekp-region--reflow (current-buffer) (ekp-region--effective-width)) + (when (timerp ekp-buffer--resize-timer) + (cancel-timer ekp-buffer--resize-timer)) + (ekp-buffer--reflow (current-buffer) (ekp-buffer--effective-width)) (redisplay t) (let ((r (ekp-gui-verify--scan (current-buffer)))) (prog1 (append (list :name name) r) @@ -161,6 +189,966 @@ In batch mode, terminate with status 1 when any result fails." (kill-emacs 1)) table)) +(defun ekp-gui-verify--pixel-width (window beg end) + "Return displayed width from BEG to END in WINDOW." + (car (window-text-pixel-size window beg end t))) + +(defun ekp-gui-verify--exact-ascii-glue (window) + "Return failed 1–64px ASCII glue targets in WINDOW." + (let ((natural (ekp-gui-verify--pixel-width window 2 3)) + failures) + (dotimes (index 64) + (let ((target (1+ index))) + (with-silent-modifications + (ekp-buffer--remove-properties 1 4) + (ekp-buffer--put-display + 2 3 (ekp-buffer--space-display natural target))) + (redisplay t) + (let ((actual (- (ekp-gui-verify--pixel-width window 1 4) + (ekp-gui-verify--pixel-width window 1 2) + (ekp-gui-verify--pixel-width window 3 4)))) + (unless (= actual target) + (push (cons target actual) failures))))) + (nreverse failures))) + +(defun ekp-gui-verify--exact-cjk-glue (window) + "Return failed 1–64px zero-source CJK glue targets in WINDOW." + (let ((first (ekp-gui-verify--pixel-width window 1 2)) + (second (ekp-gui-verify--pixel-width window 2 3)) + failures) + (dotimes (index 64) + (let ((target (1+ index))) + (with-silent-modifications + (ekp-buffer--remove-properties 1 3) + (ekp-buffer--put-display + 1 2 (ekp-buffer--min-width (+ first target)))) + (redisplay t) + (let ((actual (- (ekp-gui-verify--pixel-width window 1 3) + first second))) + (unless (= actual target) + (push (cons target actual) failures))))) + (nreverse failures))) + +(defun ekp-gui-verify--hyphen-width (text) + "Return a width that chooses a discretionary hyphen in TEXT." + (seq-find + (lambda (width) + (seq-some + (lambda (line) (ekp-layout-line-hyphen-p line)) + (append (ekp-layout-plan-lines + (ekp-layout-plan text width)) + nil))) + (number-sequence 60 220 4))) + +(defun ekp-gui-verify--hyphen-owner () + "Return the source position owning the displayed discretionary hyphen." + (seq-find + (lambda (position) + (let ((display (get-text-property position 'display))) + (and (stringp display) + (string-match-p "-\n" + (substring-no-properties display))))) + (number-sequence (point-min) (1- (point-max))))) + +(defun ekp-gui-verify--editor-semantics (window owner text) + "Verify point, region, and mouse semantics in WINDOW at OWNER for TEXT." + (let ((point-visible + (cl-loop + for position from (point-min) to (point-max) + always + (progn + (goto-char position) + (redisplay t) + (pos-visible-in-window-p position window t))))) + (goto-char (point-min)) + (let ((vertical (vertical-motion 1 window))) + (set-mark (point-min)) + (goto-char (point-max)) + (activate-mark) + (redisplay t) + (let* ((xy (pos-visible-in-window-p owner window t)) + (posn (and xy + (posn-at-x-y (car xy) (cadr xy) window))) + (mouse (and posn (posn-point posn)))) + (list + :point-visible point-visible + :vertical-motion vertical + :vertical-source-position (point) + :region-source + (and (use-region-p) + (equal (buffer-substring-no-properties + (region-beginning) (region-end)) + text)) + :mouse-source-position + (and (integer-or-marker-p mouse) + (<= (point-min) mouse (point-max)))))))) + +(defun ekp-gui-verify--hyphen-case (window text) + "Return verification data for display-only hyphenation of TEXT in WINDOW." + (let ((width (ekp-gui-verify--hyphen-width text))) + (ekp-justify-region (point-min) (point-max) width) + (redisplay t) + (let ((owner (ekp-gui-verify--hyphen-owner))) + (append + (list + :width width + :source-clean + (equal (substring-no-properties (buffer-string)) text) + :source-lines (cl-count ?\n (buffer-string)) + :screen-lines (count-screen-lines (point-min) (point-max)) + :hyphen-display (and owner t) + :overlays (length (overlays-in (point-min) (point-max))) + :scan (ekp-gui-verify--scan (current-buffer)) + :window-width (window-body-width window t)) + (ekp-gui-verify--editor-semantics window owner text))))) + +(defun ekp-gui-verify--display-properties-pass-p (result) + "Return non-nil when display-property verification RESULT passes." + (let ((hyphen (plist-get result :hyphen))) + (and (null (plist-get result :ascii-failures)) + (null (plist-get result :cjk-failures)) + (plist-get hyphen :source-clean) + (= (plist-get hyphen :source-lines) 0) + (> (plist-get hyphen :screen-lines) 1) + (plist-get hyphen :hyphen-display) + (= (plist-get hyphen :overlays) 0) + (plist-get hyphen :point-visible) + (> (plist-get hyphen :vertical-motion) 0) + (plist-get hyphen :region-source) + (plist-get hyphen :mouse-source-position) + (plist-get (plist-get hyphen :scan) :pass)))) + +;;;###autoload +(defun ekp-gui-verify-display-properties () + "Verify exact text-property glue and display-only hyphenation." + (interactive) + (unless (display-graphic-p) + (user-error "GUI verification needs a graphical frame")) + (let ((buffer (generate-new-buffer "*ekp-display-properties*")) + result) + (unwind-protect + (progn + (switch-to-buffer buffer) + (delete-other-windows) + (insert "a b") + (redisplay t) + (let ((ascii (ekp-gui-verify--exact-ascii-glue + (selected-window)))) + (erase-buffer) + (insert "中文") + (redisplay t) + (let ((cjk (ekp-gui-verify--exact-cjk-glue + (selected-window)))) + (erase-buffer) + (let ((text + "extraordinary hyphenation demonstration paragraph")) + (insert text) + (setq result + (list :ascii-failures ascii + :cjk-failures cjk + :hyphen + (ekp-gui-verify--hyphen-case + (selected-window) text))) + (setq result + (plist-put + result :pass + (ekp-gui-verify--display-properties-pass-p + result))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))) + (when (called-interactively-p 'interactive) + (message "ekp display-property verification: %s" + (if (plist-get result :pass) "PASS" "FAIL"))) + result)) + +(defvar-local ekp-gui-verify--live-original nil) +(defvar-local ekp-gui-verify--live-expected nil) +(defvar-local ekp-gui-verify--live-suffix " overflow") +(defvar-local ekp-gui-verify--live-yank " pasted 中文 semantic prefix") +(defvar-local ekp-gui-verify--live-original-signatures nil) +(defvar-local ekp-gui-verify--live-original-projection-hash nil) +(defvar-local ekp-gui-verify--live-original-active-index nil) +(defvar-local ekp-gui-verify--live-original-prefix-end nil) +(defvar-local ekp-gui-verify--live-original-width nil) +(defvar-local ekp-gui-verify--live-original-screen-lines nil) +(defvar-local ekp-gui-verify--live-post-crossing-state nil) +(defvar-local ekp-gui-verify--live-motion-generation nil) +(defvar-local ekp-gui-verify--live-motion-cache-size nil) +(defvar-local ekp-gui-verify--live-motion-state nil) +(defvar-local ekp-gui-verify--live-middle-position nil) +(defvar-local ekp-gui-verify--live-middle-later-beg nil) +(defvar-local ekp-gui-verify--live-middle-later-end nil) +(defvar-local ekp-gui-verify--live-middle-later-hash nil) +(defvar-local ekp-gui-verify--live-middle-plan nil) +(defvar-local ekp-gui-verify--live-middle-spans nil) +(defvar-local ekp-gui-verify--live-stage "setup") + +(defun ekp-gui-verify--near-overflow-text (target) + "Return prose whose natural final line is nearly full at TARGET." + (let ((words ["alpha" "中文" "beta" "排版" "gamma" "编辑"]) + (text "") + (index 0) + found) + (while (and (< index 300) (not found)) + (setq text + (concat text + (if (string-empty-p text) "" " ") + (aref words (% index (length words))))) + (let* ((plan (ekp-layout-plan text target)) + (lines (ekp-layout-plan-lines plan))) + (when (> (length lines) 2) + (let* ((line (aref lines (1- (length lines)))) + (tail (substring + text + (ekp-layout-line-source-start line) + (ekp-layout-line-source-end line))) + (remaining (- target (ekp--measured-width tail)))) + (setq found + (and (> remaining 0) + (< remaining + (ekp--measured-width + ekp-gui-verify--live-suffix))))))) + (setq index (1+ index))) + text)) + +(defun ekp-gui-verify--range-property-hash (beg end) + "Return an exact text-property hash for BEG through END." + (secure-hash + 'sha256 + (prin1-to-string (buffer-substring beg end)))) + +(defun ekp-gui-verify--configure-middle-edit () + "Record one projected space and a later stable anchor range." + (let* ((spans (ekp-buffer--live-state-spans ekp-buffer--live-state)) + (dirty (car spans)) + (later (cadr spans)) + (dirty-beg (marker-position (ekp-buffer--span-beg dirty))) + (dirty-end (marker-position (ekp-buffer--span-end dirty))) + (space + (save-excursion + (goto-char dirty-beg) + (search-forward " " dirty-end t)))) + (unless (and space later) + (error "Live GUI setup needs one dirty row and one later anchor")) + (setq ekp-gui-verify--live-middle-position (copy-marker (1- space)) + ekp-gui-verify--live-middle-later-beg + (copy-marker (marker-position (ekp-buffer--span-beg later))) + ekp-gui-verify--live-middle-later-end + (copy-marker + (marker-position (ekp-buffer--span-end (car (last spans)))) t) + ekp-gui-verify--live-middle-later-hash + (ekp-gui-verify--range-property-hash + ekp-gui-verify--live-middle-later-beg + ekp-gui-verify--live-middle-later-end) + ekp-gui-verify--live-middle-plan + (ekp-buffer--live-state-plan ekp-buffer--live-state) + ekp-gui-verify--live-middle-spans spans))) + +;;;###autoload +(defun ekp-gui-verify-live-setup () + "Create a deterministic GUI buffer for live typing verification." + (interactive) + (unless (display-graphic-p) + (user-error "GUI verification needs a graphical frame")) + (set-frame-parameter (selected-frame) 'fullscreen 'fullboth) + (delete-other-windows) + (let ((buffer (get-buffer-create "*EKP Live Layout*"))) + (switch-to-buffer buffer) + (when ekp-auto-justify-mode + (ekp-auto-justify-mode -1)) + (let ((inhibit-read-only t)) + (erase-buffer) + (text-mode) + (setq-local truncate-lines nil) + (redisplay t) + (insert (ekp-gui-verify--near-overflow-text + (ekp-buffer--effective-width))) + (setq ekp-gui-verify--live-original + (substring-no-properties (buffer-string)) + ekp-gui-verify--live-expected + ekp-gui-verify--live-original + ekp-gui-verify--live-post-crossing-state nil + ekp-gui-verify--live-motion-generation nil + ekp-gui-verify--live-motion-cache-size nil + ekp-gui-verify--live-motion-state nil + ekp-gui-verify--live-stage "setup") + (ekp-auto-justify-mode 1) + (goto-char (point-max)) + (run-hooks 'post-command-hook) + (redisplay t) + (setq ekp-gui-verify--live-original-signatures + (copy-tree + (ekp-buffer--live-state-signatures ekp-buffer--live-state)) + ekp-gui-verify--live-original-projection-hash + (ekp-gui-verify--projection-hash) + ekp-gui-verify--live-original-active-index + (ekp-buffer--live-state-active-index ekp-buffer--live-state) + ekp-gui-verify--live-original-prefix-end + (marker-position + (ekp-buffer--live-state-prefix-end ekp-buffer--live-state)) + ekp-gui-verify--live-original-width + ekp-buffer--auto-width + ekp-gui-verify--live-original-screen-lines + (count-screen-lines (point-min) (point-max))) + (ekp-gui-verify--configure-middle-edit)) + (ekp-gui-verify-live-adapter))) + +(defun ekp-gui-verify--owned-break-positions (&optional beg end) + "Return EKP-owned replacing-display newline positions from BEG to END." + (let ((position (or beg (point-min))) + (limit (or end (point-max))) + positions) + (while (< position limit) + (let* ((display (get-text-property + position 'ekp-buffer--display)) + (next (or (next-single-property-change + position 'ekp-buffer--display nil limit) + limit))) + (when (and (stringp display) + (string-match-p + "\n" (substring-no-properties display))) + (push position positions)) + (setq position next))) + (nreverse positions))) + +(defun ekp-gui-verify--owned-property-runs () + "Return stable descriptions of all EKP-owned projection runs." + (let ((position (point-min)) + runs) + (while (< position (point-max)) + (let* ((next (or (next-property-change + position nil (point-max)) + (point-max))) + (display (get-text-property + position 'ekp-buffer--display)) + (prefix (get-text-property + position 'ekp-buffer--line-prefix)) + (justified (get-text-property position 'ekp-justified))) + (when (or display prefix justified) + (push (list position next display prefix justified) runs)) + (setq position next))) + (nreverse runs))) + +(defun ekp-gui-verify--projection-hash () + "Return a stable hash of the current EKP projection." + (secure-hash + 'sha256 (prin1-to-string (ekp-gui-verify--owned-property-runs)))) + +(defun ekp-gui-verify--stable-live-state () + "Return the committed live projection state relevant to stability." + (let ((state ekp-buffer--live-state)) + (list + :active (ekp-buffer--live-state-active-index state) + :prefix-end + (marker-position (ekp-buffer--live-state-prefix-end state)) + :signatures (ekp-buffer--live-state-signatures state) + :projection (ekp-gui-verify--projection-hash) + :plan (ekp-buffer--live-state-plan state) + :spans (ekp-buffer--live-state-spans state)))) + +(defun ekp-gui-verify--stable-live-state-matches-p (snapshot) + "Return non-nil when the committed projection matches SNAPSHOT exactly." + (let ((current (ekp-gui-verify--stable-live-state))) + (and (eql (plist-get current :active) + (plist-get snapshot :active)) + (eql (plist-get current :prefix-end) + (plist-get snapshot :prefix-end)) + (eq (plist-get current :signatures) + (plist-get snapshot :signatures)) + (equal (plist-get current :projection) + (plist-get snapshot :projection)) + (eq (plist-get current :plan) + (plist-get snapshot :plan)) + (eq (plist-get current :spans) + (plist-get snapshot :spans))))) + +(defun ekp-gui-verify--dirty-live-span-p (span) + "Return non-nil when SPAN belongs to the current natural dirty island." + (when-let* ((edit ekp-buffer--live-edit) + (beg (marker-position + (ekp-buffer--live-edit-dirty-beg edit))) + (end (marker-position + (ekp-buffer--live-edit-dirty-end edit)))) + (ekp-buffer--span-overlaps-p span beg end))) + +(defun ekp-gui-verify--live-active-bounds () + "Return the active semantic source range, or nil." + (if ekp-buffer--live-edit + (let ((beg (marker-position + (ekp-buffer--live-edit-dirty-beg ekp-buffer--live-edit))) + (end (marker-position + (ekp-buffer--live-edit-dirty-end ekp-buffer--live-edit)))) + (and beg end (<= beg end) (cons beg end))) + (when-let* ((state ekp-buffer--live-state) + (plan (ekp-buffer--live-state-plan state)) + (active (ekp-buffer--live-state-active-index state)) + (beg (marker-position (ekp-buffer--live-state-beg state))) + (end (marker-position (ekp-buffer--live-state-end state)))) + (let ((lines (ekp-layout-plan-lines plan))) + (when (< active (length lines)) + (let ((line (aref lines active))) + (cons (+ beg (ekp-layout-line-source-start line)) end))))))) + +(defun ekp-gui-verify--owned-layout-p (beg end) + "Return non-nil when BEG through END carries EKP layout." + (or (text-property-not-all beg end 'ekp-buffer--display nil) + (text-property-not-all beg end 'ekp-buffer--line-prefix nil) + (text-property-not-all beg end 'ekp-justified nil))) + +(defun ekp-gui-verify--active-line-natural-p () + "Return non-nil when the active semantic line has no EKP layout." + (when-let* ((bounds (ekp-gui-verify--live-active-bounds))) + (not (ekp-gui-verify--owned-layout-p + (car bounds) (cdr bounds))))) + +(defun ekp-gui-verify--current-live-signatures () + "Return the current semantic prefix signatures." + (and ekp-buffer--live-state + (ekp-buffer--live-state-signatures ekp-buffer--live-state))) + +(defun ekp-gui-verify--stage-check (stage predicate) + "Return PREDICATE for STAGE, and true for every other stage." + (if (equal ekp-gui-verify--live-stage stage) + (if predicate t :false) + t)) + +(defun ekp-gui-verify-live-adapter () + "Return JSON-compatible state for the live verification buffer." + (let* ((live-beg + (and ekp-buffer--live-state + (marker-position + (ekp-buffer--live-state-beg ekp-buffer--live-state)))) + (live-end + (and ekp-buffer--live-state + (marker-position + (ekp-buffer--live-state-end ekp-buffer--live-state)))) + (scan (ekp-gui-verify--scan + (current-buffer) #'ekp-gui-verify--dirty-live-span-p)) + (logical (substring-no-properties (buffer-string))) + (breaks (ekp-gui-verify--owned-break-positions)) + (live-breaks + (and live-beg live-end + (ekp-gui-verify--owned-break-positions live-beg live-end))) + (active (and ekp-buffer--live-state + (ekp-buffer--live-state-active-index + ekp-buffer--live-state))) + (prefix-end + (and ekp-buffer--live-state + (marker-position + (ekp-buffer--live-state-prefix-end + ekp-buffer--live-state)))) + (signatures (ekp-gui-verify--current-live-signatures)) + (projection-hash (ekp-gui-verify--projection-hash)) + (later-hash + (and (markerp ekp-gui-verify--live-middle-later-beg) + (marker-position ekp-gui-verify--live-middle-later-beg) + (ekp-gui-verify--range-property-hash + ekp-gui-verify--live-middle-later-beg + ekp-gui-verify--live-middle-later-end))) + (original-p + (equal ekp-gui-verify--live-expected + ekp-gui-verify--live-original))) + `((logical_length . ,(length logical)) + (logical_sha256 . ,(secure-hash 'sha256 logical)) + (expected_source . ,(if (equal logical + ekp-gui-verify--live-expected) + t :false)) + (source_newlines . ,(cl-count ?\n logical)) + (owned_breaks . ,(length breaks)) + (live_owned_breaks . ,(length live-breaks)) + (stage . ,ekp-gui-verify--live-stage) + (projection_sha256 . ,projection-hash) + (active_index . ,(or active -1)) + (stable_boundary_position . ,(or prefix-end -1)) + (prefix_lines . ,(length signatures)) + (plan_lines + . ,(if-let* ((plan (and ekp-buffer--live-state + (ekp-buffer--live-state-plan + ekp-buffer--live-state)))) + (length (ekp-layout-plan-lines plan)) + 0)) + (middle_anchor_preserved + . ,(ekp-gui-verify--stage-check + "middle-dirty" + (and ekp-buffer--live-edit + (equal later-hash + ekp-gui-verify--live-middle-later-hash) + (eq (ekp-buffer--live-state-plan ekp-buffer--live-state) + ekp-gui-verify--live-middle-plan) + (eq (ekp-buffer--live-state-spans ekp-buffer--live-state) + ekp-gui-verify--live-middle-spans)))) + (middle_exact_restored + . ,(ekp-gui-verify--stage-check + "middle-restored" + (and original-p + (not ekp-buffer--live-edit) + (equal projection-hash + ekp-gui-verify--live-original-projection-hash) + (equal later-hash + ekp-gui-verify--live-middle-later-hash) + (eq (ekp-buffer--live-state-plan ekp-buffer--live-state) + ekp-gui-verify--live-middle-plan) + (eq (ekp-buffer--live-state-spans ekp-buffer--live-state) + ekp-gui-verify--live-middle-spans)))) + (prefix_replanned + . ,(ekp-gui-verify--stage-check + "typed" + (and ekp-gui-verify--live-post-crossing-state + (ekp-gui-verify--stable-live-state-matches-p + ekp-gui-verify--live-post-crossing-state) + (not (equal signatures + ekp-gui-verify--live-original-signatures))))) + (delete_preserved_committed_projection + . ,(ekp-gui-verify--stage-check + "suffix-deleted" + (and original-p + ekp-gui-verify--live-post-crossing-state + (ekp-gui-verify--stable-live-state-matches-p + ekp-gui-verify--live-post-crossing-state)))) + (point_move_back_preserved_projection + . ,(ekp-gui-verify--stage-check + "moved-back" + (and ekp-gui-verify--live-motion-generation + (= ekp-buffer--generation + ekp-gui-verify--live-motion-generation) + (= (length ekp-buffer--live-plan-cache) + ekp-gui-verify--live-motion-cache-size) + (ekp-gui-verify--stable-live-state-matches-p + ekp-gui-verify--live-motion-state)))) + (point_move_forward_preserved_projection + . ,(ekp-gui-verify--stage-check + "moved-forward" + (and ekp-gui-verify--live-motion-generation + (= ekp-buffer--generation + ekp-gui-verify--live-motion-generation) + (= (length ekp-buffer--live-plan-cache) + ekp-gui-verify--live-motion-cache-size) + (ekp-gui-verify--stable-live-state-matches-p + ekp-gui-verify--live-motion-state)))) + (yank_kept_current_row_natural + . ,(ekp-gui-verify--stage-check + "yanked" + (and ekp-buffer--live-edit + ekp-gui-verify--live-motion-state + (ekp-gui-verify--stable-live-state-matches-p + ekp-gui-verify--live-motion-state)))) + (undo_preserved_committed_projection + . ,(ekp-gui-verify--stage-check + "undone" + (and original-p + ekp-buffer--live-edit + ekp-gui-verify--live-motion-state + (ekp-gui-verify--stable-live-state-matches-p + ekp-gui-verify--live-motion-state)))) + (resize_reflowed + . ,(ekp-gui-verify--stage-check + "resized" + (< ekp-buffer--auto-width + ekp-gui-verify--live-original-width))) + (resize_restored + . ,(ekp-gui-verify--stage-check + "resize-restored" + (= ekp-buffer--auto-width + ekp-gui-verify--live-original-width))) + (completed_paragraph + . ,(if (or (not (equal ekp-gui-verify--live-stage "completed")) + (and (= (cl-count ?\n logical) 1) + (get-text-property (point-min) 'ekp-justified) + live-beg live-end + (= live-beg live-end (point-max)))) + t :false)) + (screen_lines . ,(count-screen-lines (point-min) (point-max))) + (overlays . ,(length (overlays-in (point-min) (point-max)))) + (hscroll . ,(window-hscroll)) + (active_paragraph . ,(if ekp-buffer--live-state t :false)) + (authoritative_width . ,(or ekp-buffer--auto-width 0)) + (active_line_natural + . ,(if (or (member ekp-gui-verify--live-stage + '("completed" "resized" "resize-restored")) + (ekp-gui-verify--active-line-natural-p)) + t :false)) + (generation . ,ekp-buffer--generation) + (pending_edit . ,(if ekp-buffer--live-edit t :false)) + (live_cache_size . ,(length ekp-buffer--live-plan-cache)) + (pixel_scan_pass + . ,(if (or (= (or active 0) 0) + (plist-get scan :pass)) + t :false)) + (pixel_overflow_lines . ,(plist-get scan :over))))) + +(defun ekp-gui-verify-live-assertions () + "Return adapter assertions for the live verification buffer." + (let* ((adapter (ekp-gui-verify-live-adapter)) + (value (lambda (key) (cdr (assq key adapter))))) + `(((name . "logical-source-exact") + (passed . ,(funcall value 'expected_source))) + ((name . "zero-overlays") + (passed . ,(if (= (funcall value 'overlays) 0) t :false))) + ((name . "zero-horizontal-scroll") + (passed . ,(if (= (funcall value 'hscroll) 0) t :false))) + ((name . "middle-edit-preserves-later-anchor") + (passed . ,(funcall value 'middle_anchor_preserved))) + ((name . "middle-reversal-restores-exact-projection") + (passed . ,(funcall value 'middle_exact_restored))) + ((name . "visual-row-crossing-commits-prefix") + (passed . ,(funcall value 'prefix_replanned))) + ((name . "delete-keeps-last-committed-projection") + (passed . ,(funcall value + 'delete_preserved_committed_projection))) + ((name . "point-back-preserves-projection") + (passed . ,(funcall value + 'point_move_back_preserved_projection))) + ((name . "point-forward-preserves-projection") + (passed . ,(funcall value + 'point_move_forward_preserved_projection))) + ((name . "same-row-yank-keeps-projection") + (passed . ,(funcall value 'yank_kept_current_row_natural))) + ((name . "real-undo-keeps-committed-projection") + (passed . ,(funcall value + 'undo_preserved_committed_projection))) + ((name . "window-resize-reflows-prefix") + (passed . ,(funcall value 'resize_reflowed))) + ((name . "window-width-restore-reflows-prefix") + (passed . ,(funcall value 'resize_restored))) + ((name . "hard-newline-completes-previous-paragraph") + (passed . ,(funcall value 'completed_paragraph))) + ((name . "active-semantic-line-natural") + (passed . ,(funcall value 'active_line_natural))) + ((name . "live-cache-bounded") + (passed . ,(if (<= (funcall value 'live_cache_size) 16) t :false))) + ((name . "no-pixel-overflow") + (passed . ,(if (= (funcall value 'pixel_overflow_lines) 0) + t :false)))))) + +;;;###autoload +(defun ekp-gui-verify-live-delete-middle-space () + "Delete one projected middle-row space through the public command path." + (interactive) + (goto-char (1+ (marker-position + ekp-gui-verify--live-middle-position))) + (call-interactively #'delete-backward-char) + (let ((offset (- (marker-position + ekp-gui-verify--live-middle-position) + (point-min)))) + (setq ekp-gui-verify--live-expected + (concat (substring ekp-gui-verify--live-original 0 offset) + (substring ekp-gui-verify--live-original (1+ offset))) + ekp-gui-verify--live-stage "middle-dirty")) + (redisplay t) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-restore-middle-space () + "Reinsert the deleted middle-row space and restore the exact baseline." + (interactive) + (goto-char ekp-gui-verify--live-middle-position) + (let ((last-command-event ?\s)) + (call-interactively #'self-insert-command)) + (setq ekp-gui-verify--live-expected ekp-gui-verify--live-original + ekp-gui-verify--live-stage "middle-restored") + (goto-char (point-max)) + (redisplay t) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-type-suffix () + "Type the deterministic suffix through the command loop." + (interactive) + (setq ekp-gui-verify--live-stage "typed") + (setq ekp-gui-verify--live-expected + (concat ekp-gui-verify--live-original + ekp-gui-verify--live-suffix)) + (execute-kbd-macro ekp-gui-verify--live-suffix) + (redisplay t) + (setq ekp-gui-verify--live-post-crossing-state + (ekp-gui-verify--stable-live-state)) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-delete-suffix () + "Delete the suffix while retaining the last committed projection." + (interactive) + (execute-kbd-macro + (vconcat + (make-list (length ekp-gui-verify--live-suffix) 'backspace))) + (setq ekp-gui-verify--live-expected + ekp-gui-verify--live-original + ekp-gui-verify--live-stage "suffix-deleted") + (redisplay t) + (setq ekp-gui-verify--live-motion-generation ekp-buffer--generation + ekp-gui-verify--live-motion-cache-size + (length ekp-buffer--live-plan-cache) + ekp-gui-verify--live-motion-state + (ekp-gui-verify--stable-live-state)) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-move-back () + "Move point into the first semantic line through the command loop." + (interactive) + (execute-kbd-macro (kbd "M-<")) + (setq ekp-gui-verify--live-stage "moved-back") + (redisplay t) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-move-forward () + "Move point back to the final semantic line through the command loop." + (interactive) + (execute-kbd-macro (kbd "M->")) + (setq ekp-gui-verify--live-stage "moved-forward") + (redisplay t) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-yank () + "Yank a multi-script suffix through the public command path." + (interactive) + (undo-boundary) + (kill-new ekp-gui-verify--live-yank) + (execute-kbd-macro (kbd "C-y")) + (setq ekp-gui-verify--live-expected + (concat ekp-gui-verify--live-original + ekp-gui-verify--live-yank) + ekp-gui-verify--live-stage "yanked") + (redisplay t) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-undo-yank () + "Undo the verification yank through the public command path." + (interactive) + (execute-kbd-macro (kbd "C-/")) + (setq ekp-gui-verify--live-expected + ekp-gui-verify--live-original + ekp-gui-verify--live-stage "undone") + (redisplay t) + (ekp-gui-verify-live-adapter)) + +(defun ekp-gui-verify--live-change-margin (columns stage) + "Set the right margin to COLUMNS and record verification STAGE." + (set-window-margins (selected-window) 0 columns) + (run-hooks 'window-configuration-change-hook) + (sit-for (+ ekp-auto-justify-resize-delay 0.2)) + (setq ekp-gui-verify--live-stage stage) + (redisplay t) + (ekp-gui-verify-live-adapter)) + +;;;###autoload +(defun ekp-gui-verify-live-resize-narrower () + "Narrow the live text area and wait for the real resize debounce." + (interactive) + (ekp-gui-verify--live-change-margin 24 "resized")) + +;;;###autoload +(defun ekp-gui-verify-live-resize-restore () + "Restore the live text area and wait for the real resize debounce." + (interactive) + (ekp-gui-verify--live-change-margin nil "resize-restored")) + +;;;###autoload +(defun ekp-gui-verify-live-complete-paragraph () + "Insert a hard newline and complete the previous paragraph." + (interactive) + (execute-kbd-macro "\n") + (setq ekp-gui-verify--live-expected + (concat ekp-gui-verify--live-original "\n") + ekp-gui-verify--live-stage "completed") + (redisplay t) + (ekp-gui-verify-live-adapter)) + +(defconst ekp-gui-verify--split-text + "soft wrap 中文 mixed editing stays natural across narrow side by side windows" + "Text typed by the split-window soft-wrap verification.") + +(defvar-local ekp-gui-verify--split-expected "") +(defvar-local ekp-gui-verify--split-stage "setup") +(defvar-local ekp-gui-verify--split-original-wrap nil) + +;;;###autoload +(defun ekp-gui-verify-split-wrap-setup () + "Create an intentional narrow split for native soft-wrap verification." + (interactive) + (unless (display-graphic-p) + (user-error "GUI verification needs a graphical frame")) + (set-frame-parameter (selected-frame) 'fullscreen 'fullboth) + (delete-other-windows) + (redisplay t) + (sit-for 0.2) + (let* ((target (get-buffer-create "*EKP Split Soft Wrap*")) + (control (get-buffer-create "*EKP Split Control*")) + (left (selected-window)) + (right (split-window-right))) + (set-window-buffer left target) + (set-window-buffer right control) + (window-resize left (- 44 (window-total-width left)) t) + (select-window left) + (with-current-buffer control + (erase-buffer) + (insert "Intentional control window for partial-width verification.")) + (with-current-buffer target + (when ekp-auto-justify-mode + (ekp-auto-justify-mode -1)) + (erase-buffer) + (text-mode) + (kill-local-variable 'truncate-lines) + (kill-local-variable 'truncate-partial-width-windows) + (setq ekp-gui-verify--split-expected "" + ekp-gui-verify--split-stage "setup" + ekp-gui-verify--split-original-wrap + (list (local-variable-p 'truncate-lines) + truncate-lines + (local-variable-p 'truncate-partial-width-windows) + truncate-partial-width-windows)) + (ekp-auto-justify-mode 1) + (goto-char (point-max))) + (redisplay t) + (ekp-gui-verify-split-wrap-adapter))) + +(defun ekp-gui-verify--split-row-start () + "Return point's native visual-row start in the selected window." + (save-excursion + (vertical-motion 0 (selected-window)) + (point))) + +(defun ekp-gui-verify--split-display-state-restored-p () + "Return non-nil when split verification restored its display state." + (pcase-let ((`(,lines-local ,lines ,partial-local ,partial) + ekp-gui-verify--split-original-wrap)) + (and (eq (local-variable-p 'truncate-lines) lines-local) + (equal truncate-lines lines) + (eq (local-variable-p 'truncate-partial-width-windows) + partial-local) + (equal truncate-partial-width-windows partial)))) + +(defun ekp-gui-verify-split-wrap-adapter () + "Return JSON-compatible state for split-window soft-wrap verification." + (let* ((logical (substring-no-properties (buffer-string))) + (typed-p (equal ekp-gui-verify--split-stage "typed")) + (disabled-p (equal ekp-gui-verify--split-stage "disabled")) + (breaks (ekp-gui-verify--owned-break-positions))) + `((stage . ,ekp-gui-verify--split-stage) + (expected_source + . ,(if (equal logical ekp-gui-verify--split-expected) t :false)) + (logical_sha256 . ,(secure-hash 'sha256 logical)) + (window_count . ,(length (window-list nil 'no-minibuf))) + (target_columns . ,(window-total-width)) + (auto_mode . ,(if ekp-auto-justify-mode t :false)) + (truncate_lines . ,(if truncate-lines t :false)) + (partial_truncation + . ,(if truncate-partial-width-windows t :false)) + (wrap_bindings_local + . ,(if (and (local-variable-p 'truncate-lines) + (local-variable-p 'truncate-partial-width-windows)) + t :false)) + (screen_lines . ,(count-screen-lines (point-min) (point-max))) + (visual_row_advanced + . ,(if (or (not typed-p) + (> (ekp-gui-verify--split-row-start) (point-min))) + t :false)) + (semantic_prefix_lines + . ,(if ekp-buffer--live-state + (length + (ekp-buffer--live-state-signatures + ekp-buffer--live-state)) + 0)) + (active_line_natural + . ,(if (or (not typed-p) + disabled-p + (ekp-gui-verify--active-line-natural-p)) + t :false)) + (hscroll . ,(window-hscroll)) + (live_owned_breaks . ,(length breaks)) + (overlays . ,(length (overlays-in (point-min) (point-max)))) + (pending_edit . ,(if ekp-buffer--live-edit t :false)) + (display_state_restored + . ,(if (or (not disabled-p) + (ekp-gui-verify--split-display-state-restored-p)) + t :false))))) + +(defun ekp-gui-verify-split-wrap-assertions () + "Return assertions for split-window soft-wrap verification." + (let* ((adapter (ekp-gui-verify-split-wrap-adapter)) + (value (lambda (key) (cdr (assq key adapter)))) + (disabled-p + (equal (funcall value 'stage) "disabled"))) + `(((name . "logical-source-exact") + (passed . ,(funcall value 'expected_source))) + ((name . "intentional-two-window-layout") + (passed . ,(if (= (funcall value 'window_count) 2) t :false))) + ((name . "target-window-below-default-truncation-threshold") + (passed . ,(if (< (funcall value 'target_columns) 50) t :false))) + ((name . "auto-mode-owns-soft-wrap") + (passed . ,(if (or disabled-p + (and (funcall value 'auto_mode) + (eq (funcall value 'truncate_lines) :false) + (eq (funcall value 'partial_truncation) :false) + (funcall value 'wrap_bindings_local))) + t :false))) + ((name . "typing-crosses-visual-boundary") + (passed . ,(funcall value 'visual_row_advanced))) + ((name . "no-horizontal-scroll") + (passed . ,(if (or disabled-p + (= (funcall value 'hscroll) 0)) + t :false))) + ((name . "semantic-prefix-publishes-complete-lines") + (passed . ,(if (or (not (equal (funcall value 'stage) "typed")) + (and (> (funcall value 'semantic_prefix_lines) 0) + (> (funcall value 'live_owned_breaks) 0))) + t :false))) + ((name . "active-semantic-line-natural") + (passed . ,(funcall value 'active_line_natural))) + ((name . "zero-overlays") + (passed . ,(if (= (funcall value 'overlays) 0) t :false))) + ((name . "no-pending-live-transaction") + (passed . ,(if (eq (funcall value 'pending_edit) :false) + t :false))) + ((name . "mode-disable-restores-display-state") + (passed . ,(funcall value 'display_state_restored)))))) + +(defun ekp-gui-verify-split-generic-assertions + (window buffer start end) + "Return generic capture assertions for an intentional split layout." + (list + `((name . "selected-target-window-live") + (passed . ,(if (and (window-live-p window) + (eq (window-buffer window) buffer)) + t :false))) + `((name . "intentional-two-window-capture") + (passed . ,(if (= (length (window-list nil 'no-minibuf)) 2) + t :false))) + `((name . "visible-range-valid") + (passed . ,(if (and (integer-or-marker-p start) + (integer-or-marker-p end) + (<= start end) + (<= end (with-current-buffer buffer (point-max)))) + t :false))))) + +;;;###autoload +(defun ekp-gui-verify-split-wrap-type () + "Type across the native wrap boundary in the narrow target window." + (interactive) + (mapc + (lambda (character) + (let ((last-command-event character)) + (call-interactively #'self-insert-command)) + (redisplay t) + (sit-for 0.02)) + (string-to-list ekp-gui-verify--split-text)) + (setq ekp-gui-verify--split-expected ekp-gui-verify--split-text + ekp-gui-verify--split-stage "typed") + (redisplay t) + (ekp-gui-verify-split-wrap-adapter)) + +;;;###autoload +(defun ekp-gui-verify-split-wrap-disable () + "Disable auto mode and expose restored truncation ownership." + (interactive) + (ekp-auto-justify-mode -1) + (setq ekp-gui-verify--split-stage "disabled") + (redisplay t) + (ekp-gui-verify-split-wrap-adapter)) + ;;;###autoload (defun ekp-gui-verify-matrix () "Run the display-context matrix and print a PASS/FAIL table. diff --git a/tests/ekp-region-tests.el b/tests/ekp-region-tests.el deleted file mode 100644 index 49acf3f..0000000 --- a/tests/ekp-region-tests.el +++ /dev/null @@ -1,831 +0,0 @@ -;;; ekp-region-tests.el --- Tests for ekp-region.el -*- lexical-binding: t; -*- - -;;; Commentary: - -;; Batch-safe ERT tests for the buffer-level justification layer. -;; Widths are always passed explicitly, so no window is required. - -;;; Code: - -(require 'ert) -(require 'ekp-region) - -(defconst ekp-region-test--samples - (list "简单的中文段落测试内容,排版效果应当良好稳定。" - "The quick brown fox jumps over the lazy dog several times today." - "Mixed 中英文 paragraph with double spaces inside and a tail " - "para one\n\npara two 混排 content here\nthird para" - " leading indent 段落内容 preserved intact" - "短\n\n\n多个空段落之间的内容") - "Logical texts covering CJK, Latin, mixed, blanks, indent, tails.") - -(defconst ekp-region-test--widths '(30 80 200 400) - "Pixel widths from emergency-narrow to comfortable.") - -(defmacro ekp-region-test--with-text (text &rest body) - "Run BODY in a temp buffer containing TEXT." - (declare (indent 1)) - `(with-temp-buffer - (insert ,text) - ,@body)) - -;;;; Roundtrip exactness - -(ert-deftest ekp-region-test-roundtrip-exact () - "justify + unjustify restores text and properties exactly." - (dolist (text ekp-region-test--samples) - (dolist (w ekp-region-test--widths) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) w) - (ekp-unjustify-region (point-min) (point-max)) - (should (equal-including-properties (buffer-string) text)))))) - -(ert-deftest ekp-region-test-roundtrip-propertized () - "Roundtrip preserves user text properties." - (let ((text (concat (propertize "加粗的中文开头内容" 'face 'bold) - " plain middle part " - (propertize "italic tail words" 'face 'italic)))) - (dolist (w '(60 250)) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) w) - (ekp-unjustify-region (point-min) (point-max)) - (should (equal-including-properties (buffer-string) text)))))) - -(ert-deftest ekp-region-test-hard-newlines-preserved () - "Hard newline count survives justification." - (ekp-region-test--with-text "a 段落 one\n\nb 段落 two\nc 段落 three" - (ekp-justify-region (point-min) (point-max) 100) - (let ((hard 0)) - (goto-char (point-min)) - (while (search-forward "\n" nil t) - (unless (get-text-property (match-beginning 0) 'ekp-soft-break) - (setq hard (1+ hard)))) - (should (= hard 3))))) - -;;;; Justified-state invariants - -(ert-deftest ekp-region-test-justified-marked () - "Justified region carries the ekp-justified width property." - (ekp-region-test--with-text "中文内容需要标记属性验证正确性" - (ekp-justify-region (point-min) (point-max) 120) - (should (eq (get-text-property (point-min) 'ekp-justified) 120)) - (should-not (text-property-not-all (point-min) (point-max) - 'ekp-justified 120)))) - -(ert-deftest ekp-region-test-rejustify-idempotent () - "Justifying at a new width equals a fresh justification at that width." - (let ((text "The idempotence check 中英混排 must hold across widths.")) - (let (fresh) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 150) - (setq fresh (buffer-string))) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 300) - (ekp-justify-region (point-min) (point-max) 150) - (should (equal-including-properties (buffer-string) fresh)))))) - -;;;; Edit robustness - -(ert-deftest ekp-region-test-edit-then-unjustify () - "Text typed into a justified buffer survives unjustification." - (ekp-region-test--with-text "abcdef ghijkl 中文内容 mnopqr stuvwx" - (ekp-justify-region (point-min) (point-max) 80) - ;; Insert inside the first word: physical == logical there. - (goto-char (+ (point-min) 2)) - (insert "XY") - (ekp-unjustify-region (point-min) (point-max)) - (should (equal (buffer-string) - "abXYcdef ghijkl 中文内容 mnopqr stuvwx")))) - -(ert-deftest ekp-region-test-point-stable () - "Point returns to its logical position after a roundtrip." - (ekp-region-test--with-text "abcdef ghijkl mnopqr stuvwx yzabcd" - (goto-char (+ (point-min) 9)) ; inside "ghijkl" - (ekp-justify-region (point-min) (point-max) 60) - (ekp-unjustify-region (point-min) (point-max)) - (should (= (point) (+ (point-min) 9))))) - -;;;; Auto-justify mode - -(defmacro ekp-region-test--with-mode (text width &rest body) - "Enable `ekp-auto-justify-mode' on TEXT at WIDTH, run BODY, disable." - (declare (indent 2)) - `(ekp-region-test--with-text ,text - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) ,width))) - (ekp-auto-justify-mode 1) - (unwind-protect - (progn ,@body) - (ekp-auto-justify-mode -1))))) - -(ert-deftest ekp-region-test-mode-roundtrip () - "Enabling then disabling the mode restores the buffer exactly." - (let ((text "first paragraph 内容 aaa bbb ccc\nsecond paragraph 内容 ddd")) - (ekp-region-test--with-mode text 150 - (should ekp-region--auto-width) - (should (get-text-property (point-min) 'ekp-justified))) - ;; body ran; with-mode disabled the mode on exit — verify restore - (ekp-region-test--with-text text - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 150))) - (ekp-auto-justify-mode 1) - (ekp-auto-justify-mode -1) - (should (equal-including-properties (buffer-string) text)))))) - -(ert-deftest ekp-region-test-mode-incremental-edit () - "Edits re-justify only the touched paragraph, content stays correct." - (let ((text "aaa bbb ccc ddd eee fff\nggg hhh iii jjj kkk lll") - (calls nil)) - (ekp-region-test--with-mode text 100 - (let ((orig (symbol-function 'ekp-justify-region))) - (cl-letf (((symbol-function 'ekp-justify-region) - (lambda (b e &optional px) - (push (cons (marker-position (copy-marker b)) - (marker-position (copy-marker e))) - calls) - (funcall orig b e px)))) - ;; Edit inside paragraph 1. - (goto-char (+ (point-min) 4)) - (insert "zz") - (should ekp-region--dirty) - (ekp-region--flush-dirty (current-buffer)) - ;; Exactly one incremental call, confined before the hard \n. - (should (= (length calls) 1)) - (let ((hard-nl (save-excursion - (goto-char (point-min)) - (catch 'nl - (while (search-forward "\n" nil t) - (unless (get-text-property (match-beginning 0) - 'ekp-soft-break) - (throw 'nl (match-beginning 0)))))))) - (should (<= (cdar calls) hard-nl))))) - ;; Logical text after disable = original with the edit applied. - (ekp-auto-justify-mode -1) - (should (equal (buffer-string) - "aaa zzbbb ccc ddd eee fff\nggg hhh iii jjj kkk lll")) - ;; re-enable so with-mode's cleanup disable is a no-op state-wise - (ekp-auto-justify-mode 1)))) - -(ert-deftest ekp-region-test-verbatim-paragraph-skipped () - "A code-block paragraph stays byte-identical; prose around it justifies." - (let* ((code (propertize "(defun foo (x) (list 1 2))" - 'ekp-verbatim t 'face 'font-lock-keyword-face)) - (text (concat "prose before with words enough to wrap lines\n" - code - "\nprose after also long enough to wrap lines"))) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 20) - ;; the code line is still there, character-exact, spacing intact - (goto-char (point-min)) - (should (search-forward "(defun foo (x) (list 1 2))" nil t)) - ;; prose got justified (soft breaks appeared) - (should (text-property-not-all (point-min) (point-max) - 'ekp-soft-break nil)) - (ekp-unjustify-region (point-min) (point-max)) - (should (equal-including-properties (buffer-string) text))))) - -(ert-deftest ekp-region-test-skip-faces () - "Paragraphs wearing a skip face stay verbatim." - (let* ((ekp-region-skip-faces '(font-lock-comment-face)) - (code (propertize ";; a comment line kept as-is" - 'face 'font-lock-comment-face)) - (text (concat "prose paragraph long enough to wrap\n" code))) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 15) - (goto-char (point-min)) - (should (search-forward ";; a comment line kept as-is" nil t)) - (ekp-unjustify-region (point-min) (point-max)) - (should (equal-including-properties (buffer-string) text))))) - -(ert-deftest ekp-region-test-skip-predicate () - "The paragraph predicate is the general escape hatch." - (let ((text "keepme raw spacing\nnormal prose that wraps around")) - (ekp-region-test--with-text text - (setq-local ekp-region-skip-predicate - (lambda (p) (string-prefix-p "keepme" p))) - (ekp-justify-region (point-min) (point-max) 12) - (goto-char (point-min)) - (should (looking-at-p "keepme raw spacing$")) - (ekp-unjustify-region (point-min) (point-max)) - (should (equal (buffer-string) text))))) - -(ert-deftest ekp-region-test-indent-roundtrip () - "First-line indent spacers vanish exactly on unjustify." - (let ((ekp-first-line-indent 6) - (text "首行缩进往返检查内容足够长断行几次")) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 30) - (ekp-unjustify-region (point-min) (point-max)) - (should (equal-including-properties (buffer-string) text))))) - -(ert-deftest ekp-region-test-resize-hook-window-arg () - "The resize hook handles its WINDOW argument and foreign current buffer. -Regression: buffer-local `window-size-change-functions' members get -the displaying WINDOW, with an arbitrary buffer current." - (let ((text "resize hook 检查 aaa bbb ccc ddd eee fff")) - (ekp-region-test--with-mode text 200 - (let ((buf (current-buffer)) - (win (selected-window))) - (set-window-buffer win buf) - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 120))) - ;; simulate redisplay: window argument, unrelated buffer current - (with-temp-buffer - (ekp-region--on-resize win))) - (with-current-buffer buf - (should (timerp ekp-region--resize-timer)) - (cancel-timer ekp-region--resize-timer) - ;; run what the timer would have run - (ekp-region--reflow buf 120) - (should (= ekp-region--auto-width 120))))))) - -(ert-deftest ekp-region-test-mode-reflow-width () - "Reflow to a new width matches a fresh justification at that width." - (let ((text "reflow 检查 aaa bbb ccc ddd eee fff ggg hhh") - fresh) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 90) - (setq fresh (buffer-substring (point-min) (point-max)))) - (ekp-region-test--with-mode text 200 - (ekp-region--reflow (current-buffer) 90) - (should (= ekp-region--auto-width 90)) - (let ((got (buffer-substring (point-min) (point-max)))) - ;; ekp-justified was written at two widths; ignore that prop - (remove-text-properties 0 (length got) '(ekp-justified nil) got) - (remove-text-properties 0 (length fresh) '(ekp-justified nil) fresh) - (should (equal-including-properties got fresh)))))) - - -(ert-deftest ekp-region-test-lazy-reflow-equals-oneshot () - "Visible-first chunked re-flow converges to the one-shot result." - (let ((ekp-auto-justify-lazy-threshold 100) - (ekp-auto-justify-chunk-size 3) - (text (mapconcat #'identity - (make-list 12 "段落内容 some words 足够长会换行的样子") - "\n"))) - (ekp-region-test--with-text text - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 60)) - ((symbol-function 'ekp-region--visible-span) - (lambda () (cons (point-min) (min (point-max) 80))))) - (ekp-auto-justify-mode 1) - (ekp-region--reflow (current-buffer) 50) - (should ekp-region--pending) - ;; drain the background queue synchronously - (let ((guard 0)) - (while (and ekp-region--pending (< guard 100)) - (when (timerp ekp-region--chunk-timer) - (cancel-timer ekp-region--chunk-timer) - (setq ekp-region--chunk-timer nil)) - (ekp-region--process-chunk (current-buffer)) - (setq guard (1+ guard)))) - (should-not ekp-region--pending) - (let ((lazy (buffer-string))) - (ekp-auto-justify-mode -1) - (ekp-justify-region (point-min) (point-max) 50) - (should (equal-including-properties (buffer-string) lazy))))))) - -;;;; Editor-state integrity (save / modified / undo / stickiness) - -(ert-deftest ekp-region-test-typed-char-inherits-no-marker () - "Text typed right after a glue must not inherit renderer markers. -Regression: `self-insert-command' uses insert-and-inherit; a char -inheriting `ekp-glue' was deleted as a synthesized space by the next -unjustification." - (ekp-region-test--with-text "aaa bbb 中文 ccc" - (ekp-justify-region (point-min) (point-max) 200) - (let ((glue-pos (text-property-not-all (point-min) (point-max) - 'ekp-glue nil))) - (should glue-pos) - (goto-char (1+ glue-pos)) - (insert-and-inherit "X") - (let ((x (1+ glue-pos))) - (should-not (get-text-property x 'ekp-glue)) - (should-not (get-text-property x 'display)) - (should-not (get-text-property x 'ekp-soft-break)))) - (ekp-unjustify-region (point-min) (point-max)) - (should (= 1 (cl-count ?X (buffer-string)))))) - -(ert-deftest ekp-region-test-save-writes-logical-text () - "Saving a justified file buffer writes the logical text to disk, -keeps the buffer justified, and leaves it unmodified." - (let* ((file (make-temp-file "ekp-save-test")) - (text "中文保存测试内容足够长会断行的样子,再加一句凑长度。") - (make-backup-files nil) - (create-lockfiles nil)) - (unwind-protect - (with-current-buffer (find-file-noselect file) - (insert text) - (ekp-justify-region (point-min) (point-max) 20) - (should (> (cl-count ?\n (buffer-string)) 0)) - (save-buffer) - ;; Disk: logical text only, no layout newlines. - (should (equal (with-temp-buffer - (insert-file-contents file) - (buffer-string)) - text)) - ;; Buffer: still justified, and not "modified" vs its file. - (should (get-text-property (point-min) 'ekp-justified)) - (should-not (buffer-modified-p)) - ;; And a second save still works (state was reset). - (insert "x") - (goto-char (point-min)) - (save-buffer) - (should (equal (with-temp-buffer - (insert-file-contents file) - (buffer-string)) - (concat text "x"))) - (let ((kill-buffer-query-functions nil)) - (kill-buffer))) - (delete-file file)))) - -(ert-deftest ekp-region-test-failed-save-preserves-layout () - "A filesystem save failure must not leave the buffer unformatted." - (let* ((dir (make-temp-file "ekp-save-fail-" t)) - (file (expand-file-name "file.txt" dir)) - (text "保存失败以后屏幕仍然保持排版状态 and remains editable") - (make-backup-files nil) - (create-lockfiles nil)) - (unwind-protect - (with-current-buffer (find-file-noselect file) - (setq-local require-final-newline nil) - (insert text) - (ekp-justify-region (point-min) (point-max) 20) - (let ((layout (buffer-substring (point-min) (point-max)))) - (delete-directory dir t) - (cl-letf (((symbol-function 'y-or-n-p) - (lambda (&rest _) nil))) - (should-error (save-buffer))) - (should (equal-including-properties - (buffer-substring (point-min) (point-max)) layout)) - (should (get-text-property (point-min) 'ekp-justified)) - (should (buffer-modified-p))) - (let ((kill-buffer-query-functions nil)) - (set-buffer-modified-p nil) - (kill-buffer))) - (when (file-directory-p dir) - (delete-directory dir t))))) - -(ert-deftest ekp-region-test-interrupted-save-preserves-layout () - "A quit during writing must not leave the buffer unformatted." - (let* ((file (make-temp-file "ekp-save-quit-")) - (text "保存中断以后屏幕排版状态必须原样保留 with logical text") - (make-backup-files nil) - (create-lockfiles nil)) - (unwind-protect - (with-current-buffer (find-file-noselect file) - (setq-local require-final-newline nil) - (insert text) - (ekp-justify-region (point-min) (point-max) 20) - (let ((layout (buffer-substring (point-min) (point-max)))) - (cl-letf (((symbol-function 'write-region) - (lambda (&rest _) (signal 'quit nil)))) - (should (condition-case nil - (progn (save-buffer) nil) - (quit t)))) - (should (equal-including-properties - (buffer-substring (point-min) (point-max)) layout)) - (should (buffer-modified-p))) - (let ((kill-buffer-query-functions nil)) - (set-buffer-modified-p nil) - (kill-buffer))) - (delete-file file)))) - -(ert-deftest ekp-region-test-encoding-save-failure-is-retryable () - "An encoding failure must preserve layout and allow a clean retry." - (let* ((file (make-temp-file "ekp-save-encoding-")) - (text "编码失败以后仍然保持排版,重试写入 logical text") - (make-backup-files nil) - (create-lockfiles nil)) - (unwind-protect - (with-current-buffer (find-file-noselect file) - (setq-local require-final-newline nil) - (insert text) - (ekp-justify-region (point-min) (point-max) 20) - (let ((layout (buffer-substring (point-min) (point-max)))) - (set-buffer-file-coding-system 'us-ascii-unix) - (cl-letf (((symbol-function 'select-safe-coding-system) - (lambda (&rest _) (error "Forced encoding failure")))) - (should-error (save-buffer))) - (should (equal-including-properties - (buffer-substring (point-min) (point-max)) layout)) - (should (buffer-modified-p)) - (set-buffer-file-coding-system 'utf-8-unix) - (save-buffer) - (should (equal-including-properties - (buffer-substring (point-min) (point-max)) layout)) - (should-not (buffer-live-p ekp-region--write-buffer)) - (should (equal (with-temp-buffer - (insert-file-contents file) - (buffer-string)) - text))) - (let ((kill-buffer-query-functions nil)) - (kill-buffer))) - (delete-file file)))) - -(ert-deftest ekp-region-test-justify-preserves-unmodified () - "Pure re-layout must not flip `buffer-modified-p'." - (let* ((file (make-temp-file "ekp-mod-test")) - (make-backup-files nil) - (create-lockfiles nil)) - (unwind-protect - (with-current-buffer (find-file-noselect file) - (insert "modified 标志保持检查内容足够长断行") - (save-buffer) - (should-not (buffer-modified-p)) - (ekp-justify-region (point-min) (point-max) 30) - (should-not (buffer-modified-p)) - (ekp-unjustify-region (point-min) (point-max)) - (should-not (buffer-modified-p)) - ;; A real edit still marks the buffer modified. - (insert "y") - (should (buffer-modified-p)) - (let ((kill-buffer-query-functions nil)) - (set-buffer-modified-p nil) - (kill-buffer))) - (delete-file file)))) - -(ert-deftest ekp-region-test-undo-changes-not-redirtied () - "Changes applied by undo must not schedule a re-flow." - (ekp-region-test--with-mode "undo guard 检查内容 aaa bbb ccc" 100 - (setq ekp-region--dirty nil) - (let ((undo-in-progress t)) - (ekp-region--after-change (point-min) (1+ (point-min)) 0)) - (should-not ekp-region--dirty) - (let ((undo-in-progress nil)) - (ekp-region--after-change (point-min) (1+ (point-min)) 0)) - (should ekp-region--dirty) - (dolist (p ekp-region--dirty) - (set-marker (car p) nil) - (set-marker (cdr p) nil)) - (setq ekp-region--dirty nil) - (when (timerp ekp-region--edit-timer) - (cancel-timer ekp-region--edit-timer)))) - -(ert-deftest ekp-region-test-major-mode-change-restores () - "Switching major mode tears the justified state down cleanly." - (let ((text "major mode 切换检查 aaa bbb ccc ddd")) - (ekp-region-test--with-text text - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 80))) - (ekp-auto-justify-mode 1) - (should (get-text-property (point-min) 'ekp-justified)) - (fundamental-mode) - (should (equal (buffer-string) text)) - (should-not ekp-auto-justify-mode))))) - -;;;; Ecosystem compatibility (kill ring / isearch / fields / read-only) - -(ert-deftest ekp-region-test-kill-ring-gets-logical-text () - "Copying justified text extracts the logical text. -CJK justification injects real space characters between glyphs; -they must not travel with a kill/yank." - (let ((text "中文复制检查内容足够长会断行 with some latin")) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 20) - (should (local-variable-p 'filter-buffer-substring-function)) - (should (equal (filter-buffer-substring (point-min) (point-max)) - text))))) - -(ert-deftest ekp-region-test-copy-filter-composes-and-restores () - "EKP must preserve an existing buffer-local substring filter." - (let ((text "组合复制过滤器必须保留 logical text and prefix")) - (ekp-region-test--with-text text - (let ((prior (lambda (beg end &optional delete) - (let ((text (buffer-substring beg end))) - (when delete (delete-region beg end)) - (concat "PRE:" text))))) - (setq-local filter-buffer-substring-function prior) - (ekp-justify-region (point-min) (point-max) 20) - (should (equal (filter-buffer-substring (point-min) (point-max)) - (concat "PRE:" text))) - (ekp-unjustify-region (point-min) (point-max)) - (should (local-variable-p 'filter-buffer-substring-function)) - (should (eq filter-buffer-substring-function prior)))))) - -(ert-deftest ekp-region-test-kill-filter-composes-delete () - "Composed filtering must preserve DELETE and prior-filter semantics." - (let ((text "组合 kill 过滤器删除源文本但返回 logical text")) - (ekp-region-test--with-text text - (let ((prior (lambda (beg end &optional delete) - (let ((text (buffer-substring beg end))) - (when delete (delete-region beg end)) - (concat "PRE:" text))))) - (setq-local filter-buffer-substring-function prior) - (ekp-justify-region (point-min) (point-max) 20) - (should (equal (filter-buffer-substring - (point-min) (point-max) t) - (concat "PRE:" text))) - (should (= (point-min) (point-max))) - (should (eq filter-buffer-substring-function prior)))))) - -(ert-deftest ekp-region-test-restores-inherited-copy-filter () - "Final unjustify must reveal an inherited substring filter again." - (let ((prior (lambda (beg end &optional delete) - (prog1 (buffer-substring beg end) - (when delete (delete-region beg end)))))) - (let ((filter-buffer-substring-function prior)) - (ekp-region-test--with-text "继承 filter 恢复检查内容" - (ekp-justify-region (point-min) (point-max) 20) - (ekp-unjustify-region (point-min) (point-max)) - (should-not (local-variable-p 'filter-buffer-substring-function)) - (should (eq filter-buffer-substring-function prior)))))) - -(ert-deftest ekp-region-test-mode-disable-restores-copy-filter () - "Disabling auto mode must restore the previous local copy filter." - (let ((prior (lambda (beg end &optional delete) - (prog1 (buffer-substring beg end) - (when delete (delete-region beg end)))))) - (ekp-region-test--with-text "关闭 mode 恢复已有 copy filter" - (setq-local filter-buffer-substring-function prior) - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 20))) - (ekp-auto-justify-mode 1) - (ekp-auto-justify-mode -1)) - (should (local-variable-p 'filter-buffer-substring-function)) - (should (eq filter-buffer-substring-function prior))))) - -(ert-deftest ekp-region-test-final-unjustify-removes-integrations () - "Removing the final layout span must remove unused integrations." - (ekp-region-test--with-text "最后一个排版区间移除后清理集成 hooks" - (ekp-justify-region (point-min) (point-max) 20) - (should (memq #'ekp-region--write-logical-buffer - write-region-annotate-functions)) - (should (memq #'ekp-region--isearch-begin isearch-mode-hook)) - (ekp-unjustify-region (point-min) (point-max)) - (should-not (local-variable-p 'filter-buffer-substring-function)) - (should-not (memq #'ekp-region--write-logical-buffer - write-region-annotate-functions)) - (should-not (memq #'ekp-region--isearch-begin isearch-mode-hook)))) - -(ert-deftest ekp-region-test-isearch-sees-logical-text () - "The isearch hooks expose the logical text, then restore the layout." - (let ((text "跨行搜索的目标短语必须能找到 internationalization word")) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 20) - (let ((justified (buffer-string))) - ;; Sanity: layout breaks the phrase apart. - (should (> (cl-count ?\n justified) 0)) - (ekp-region--isearch-begin) - ;; Logical view: the full phrase and the long word are findable. - (goto-char (point-min)) - (should (search-forward "目标短语必须能找到" nil t)) - (goto-char (point-min)) - (should (search-forward "internationalization" nil t)) - (ekp-region--isearch-end) - ;; Layout restored byte-identically. - (should (equal-including-properties (buffer-string) justified)))))) - -(ert-deftest ekp-region-test-field-paragraph-skipped () - "Paragraphs containing field or read-only text stay verbatim." - (let* ((prompt (propertize "shell> " 'field 'output)) - (text (concat prompt "command output here\n" - "prose paragraph long enough to wrap around"))) - (ekp-region-test--with-text text - (ekp-justify-region (point-min) (point-max) 15) - (goto-char (point-min)) - (should (search-forward "command output here" nil t))))) - -(ert-deftest ekp-region-test-read-only-command-barfs () - "Interactive justify on a read-only buffer signals, not corrupts." - (ekp-region-test--with-text "read only 检查内容" - (set-mark (point-min)) - (goto-char (point-max)) - (activate-mark) - (read-only-mode 1) - (should-error (call-interactively #'ekp-justify-region) - :type 'buffer-read-only))) - -;;;; Commands and mode integration - -(ert-deftest ekp-region-test-no-break-public-commands () - "Interactive no-break commands affect the public formatter and report scope." - (ekp-region-test--with-text "prefix AA BB suffix words" - (let (messages) - (set-mark 8) - (goto-char 13) - (activate-mark) - (cl-letf (((symbol-function 'message) - (lambda (format-string &rest args) - (push (apply #'format format-string args) messages)))) - (call-interactively #'ekp-no-break-region)) - (should (eq (get-text-property 8 'ekp-no-break) t)) - (ekp-justify-region (point-min) (point-max) 4) - (should (string-match-p "AA BB" (buffer-string))) - (ekp-unjustify-region (point-min) (point-max)) - (set-mark 8) - (goto-char 13) - (activate-mark) - (cl-letf (((symbol-function 'message) - (lambda (format-string &rest args) - (push (apply #'format format-string args) messages)))) - (call-interactively #'ekp-allow-break-region)) - (should-not (get-text-property 8 'ekp-no-break)) - (should (= (length messages) 2)) - (should (cl-every - (lambda (text) - (string-match-p "current buffer session" text)) - messages))))) - -(ert-deftest ekp-region-test-verbatim-public-commands () - "Interactive verbatim commands protect the real region formatter." - (ekp-region-test--with-text - "literal block stays exactly here\nordinary prose wraps here" - (goto-char (point-min)) - (let ((first-end (line-end-position)) messages) - (set-mark (point-min)) - (goto-char first-end) - (activate-mark) - (cl-letf (((symbol-function 'message) - (lambda (format-string &rest args) - (push (apply #'format format-string args) messages)))) - (call-interactively #'ekp-verbatim-region)) - (ekp-justify-region (point-min) (point-max) 8) - (should (equal (buffer-substring-no-properties - (point-min) (line-end-position)) - "literal block stays exactly here")) - (ekp-unjustify-region (point-min) (point-max)) - (set-mark (point-min)) - (goto-char first-end) - (activate-mark) - (cl-letf (((symbol-function 'message) - (lambda (format-string &rest args) - (push (apply #'format format-string args) messages)))) - (call-interactively #'ekp-clear-verbatim-region)) - (ekp-justify-region (point-min) first-end 8) - (should (get-text-property (point-min) 'ekp-justified)) - (should (= (length messages) 2)) - (should (cl-every - (lambda (text) - (string-match-p "current buffer session" text)) - messages))))) - -(ert-deftest ekp-region-test-protection-workflows-discoverable () - "Mode help and menu expose the existing protection workflows." - (should (string-match-p - "current buffer session" - (documentation #'ekp-auto-justify-mode))) - (let ((menu (lookup-key ekp-auto-justify-mode-map [menu-bar ekp]))) - (should (keymapp menu)) - (should (where-is-internal - #'ekp-no-break-region ekp-auto-justify-mode-map)) - (should (where-is-internal - #'ekp-verbatim-region ekp-auto-justify-mode-map)))) - -(ert-deftest ekp-region-test-justify-buffer-roundtrip () - "ekp-justify-buffer / ekp-unjustify-buffer cover the whole buffer." - (let ((text "第一段内容足够长断行\n\n第二段 also long enough to wrap")) - (ekp-region-test--with-text text - (ekp-justify-buffer 25) - (should (get-text-property (point-min) 'ekp-justified)) - (ekp-unjustify-buffer) - (should (equal-including-properties (buffer-string) text))))) - -(ert-deftest ekp-region-test-justify-dwim-paragraph () - "Without an active region, the commands act on the paragraph at point." - (ekp-region-test--with-text - "para one short\npara two 目标段落内容足够长会断行几次\npara three" - (goto-char (point-min)) - (search-forward "目标") - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 20))) - (call-interactively #'ekp-justify-region)) - ;; Only paragraph two is justified. - (goto-char (point-min)) - (should-not (get-text-property (point) 'ekp-justified)) - (search-forward "目标") - (should (get-text-property (match-beginning 0) 'ekp-justified)) - (goto-char (point-max)) - (should-not (get-text-property (1- (point)) 'ekp-justified)) - ;; And unjustify DWIM restores just as well. - (goto-char (point-min)) - (search-forward "目标") - (call-interactively #'ekp-unjustify-region) - (should (equal (buffer-string) - "para one short\npara two 目标段落内容足够长会断行几次\npara three")))) - -(ert-deftest ekp-region-test-refill-paragraph () - "`ekp-refill-paragraph' re-justifies the paragraph at point." - (ekp-region-test--with-text "refill 检查内容足够长会断行几次的样子\nsecond para" - (goto-char (point-min)) - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 20))) - (ekp-refill-paragraph)) - (should (get-text-property (point-min) 'ekp-justified)) - (goto-char (point-max)) - (should-not (get-text-property (1- (point)) 'ekp-justified)))) - -(ert-deftest ekp-region-test-markdown-setup () - "ekp-markdown-setup stops font-lock from managing `display'." - (with-temp-buffer - (setq-local font-lock-extra-managed-props '(display composition)) - (ekp-markdown-setup) - (should (equal font-lock-extra-managed-props '(composition))) - (should (equal ekp-region-skip-faces ekp-region-markdown-skip-faces)))) - -(ert-deftest ekp-region-test-org-auto-preset () - "Enabling the mode in an Org buffer applies the Org skip preset." - (with-temp-buffer - (org-mode) - (insert "普通正文段落内容足够长断行几次的样子") - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 100))) - (ekp-auto-justify-mode 1) - (unwind-protect - (should (equal ekp-region-skip-faces ekp-region-org-skip-faces)) - (ekp-auto-justify-mode -1))))) - -;;;; Lazy re-flow scheduling - -(ert-deftest ekp-region-test-huge-edit-goes-lazy () - "A dirty region larger than the lazy threshold is chunked, not sync." - (let ((ekp-auto-justify-lazy-threshold 50) - (ekp-auto-justify-chunk-size 2) - (text (mapconcat #'identity - (make-list 10 "大量粘贴模拟内容足够长会断行") - "\n"))) - (ekp-region-test--with-text text - (cl-letf (((symbol-function 'ekp-region--window-pixel) - (lambda (&optional _) 60))) - (ekp-auto-justify-mode 1) - (unwind-protect - (progn - ;; drain the enable-time lazy queue first - (let ((ekp-auto-justify-tick-budget 10.0) (guard 0)) - (while (and ekp-region--pending (< guard 100)) - (when (timerp ekp-region--chunk-timer) - (cancel-timer ekp-region--chunk-timer) - (setq ekp-region--chunk-timer nil)) - (ekp-region--process-chunk (current-buffer)) - (setq guard (1+ guard)))) - ;; simulate a huge edit: whole buffer marked dirty - (push (cons (copy-marker (point-min)) - (copy-marker (point-max))) - ekp-region--dirty) - (when (timerp ekp-region--edit-timer) - (cancel-timer ekp-region--edit-timer)) - (ekp-region--flush-dirty (current-buffer)) - ;; not processed synchronously: a queue exists - (should ekp-region--pending) - ;; drain and verify convergence to the one-shot result - (let ((ekp-auto-justify-tick-budget 10.0) (guard 0)) - (while (and ekp-region--pending (< guard 100)) - (when (timerp ekp-region--chunk-timer) - (cancel-timer ekp-region--chunk-timer) - (setq ekp-region--chunk-timer nil)) - (ekp-region--process-chunk (current-buffer)) - (setq guard (1+ guard)))) - (should-not ekp-region--pending) - (let ((lazy (buffer-string))) - (ekp-auto-justify-mode -1) - (ekp-justify-region (point-min) (point-max) 60) - (should (equal-including-properties (buffer-string) lazy)) - (ekp-auto-justify-mode 1))) - (ekp-auto-justify-mode -1)))))) - -(ert-deftest ekp-region-test-prioritize-visible-chunks () - "Chunks intersecting the visible span move to the queue front." - (ekp-region-test--with-text "abc" - (setq ekp-region--auto-width 100) - (let* ((mk (lambda (a b) (cons (copy-marker a) (copy-marker b)))) - (c1 (funcall mk 1 2)) - (c2 (funcall mk 2 3)) - (c3 (funcall mk 3 4))) - (setq ekp-region--pending (cons 100 (list c1 c2 c3))) - (cl-letf (((symbol-function 'ekp-region--visible-span) - (lambda () (cons 3 4)))) - (ekp-region--prioritize-visible)) - (should (eq (cadr ekp-region--pending) c3)) - (ekp-region--cancel-pending)))) - -(ert-deftest ekp-region-test-tick-budget-batches-chunks () - "A generous tick budget drains several chunks in one tick; -a zero budget still makes progress (exactly one chunk)." - (let ((text (mapconcat #'identity - (make-list 6 "分块预算检查内容足够长") - "\n"))) - (ekp-region-test--with-text text - (setq ekp-region--auto-width 40) - (setq ekp-region--pending - (cons 40 (ekp-region--make-chunks (point-min) (point-max)))) - (setq-local ekp-auto-justify-chunk-size 1) - ;; zero budget: one chunk per tick - (let ((ekp-auto-justify-tick-budget 0) - (before (length (cdr ekp-region--pending)))) - (cl-letf (((symbol-function 'input-pending-p) #'ignore)) - (let ((ekp-auto-justify-mode t)) - (ekp-region--process-chunk (current-buffer)))) - (should (= (length (cdr ekp-region--pending)) (1- before)))) - (when (timerp ekp-region--chunk-timer) - (cancel-timer ekp-region--chunk-timer) - (setq ekp-region--chunk-timer nil)) - ;; big budget: the rest drains in one tick - (let ((ekp-auto-justify-tick-budget 10.0)) - (cl-letf (((symbol-function 'input-pending-p) #'ignore)) - (let ((ekp-auto-justify-mode t)) - (ekp-region--process-chunk (current-buffer))))) - (should-not ekp-region--pending)))) - -(provide 'ekp-region-tests) - -;;; ekp-region-tests.el ends here diff --git a/tests/ekp-showcase.el b/tests/ekp-showcase.el index e70ff06..c8d9372 100644 --- a/tests/ekp-showcase.el +++ b/tests/ekp-showcase.el @@ -22,7 +22,7 @@ ;;; Code: (require 'ekp) -(require 'ekp-region) +(require 'ekp-buffer) (defvar ekp-showcase-min-width 140) (defvar ekp-showcase-max-width 1200) diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index 351b7e1..2b4634e 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -750,6 +750,92 @@ module is bypassed automatically (it has no looseness support)." (dolist (property ekp--layout-marker-properties) (should (eq (alist-get property text-property-default-nonsticky) t)))) +(ert-deftest ekp-test-layout-plan-maps-source-gaps-and-breaks () + "The semantic plan must retain source offsets for every visual decision." + (ekp-tests--with-clean-state + (let* ((text "中文 Latin mixed paragraph with enough words to wrap") + (plan (ekp-layout-plan text 24)) + (lines (ekp-layout-plan-lines plan)) + (last-end 0)) + (should (ekp-layout-plan-p plan)) + (should (equal (ekp-layout-plan-string plan) text)) + (should (= (ekp-layout-plan-line-pixel plan) 24)) + (should (> (length lines) 1)) + (dotimes (i (length lines)) + (let ((line (aref lines i))) + (should (<= last-end (ekp-layout-line-source-start line))) + (should (< (ekp-layout-line-source-start line) + (ekp-layout-line-source-end line))) + (dolist (gap (append (ekp-layout-line-gaps line) nil)) + (should (memq (ekp-layout-gap-kind gap) '(lws mws cws nws))) + (should (<= (ekp-layout-gap-source-start gap) + (ekp-layout-gap-source-end gap))) + (should (>= (ekp-layout-gap-target-pixel gap) 0))) + (when (< i (1- (length lines))) + (should (memq (ekp-layout-line-break-kind line) + '(space cjk hyphen)))) + (setq last-end (ekp-layout-line-source-end line))))))) + +(ert-deftest ekp-test-layout-plan-resolves-paragraph-once () + "One plan must not rebuild the same paragraph cache key downstream." + (ekp-tests--with-clean-state + (let ((calls 0) + (get-para (symbol-function 'ekp--get-para))) + (cl-letf (((symbol-function 'ekp--get-para) + (lambda (string) + (setq calls (1+ calls)) + (funcall get-para string)))) + (ekp-layout-plan + "A mixed 中文 paragraph should resolve one cached paragraph object." + 24)) + (should (= calls 1))))) + +(ert-deftest ekp-test-layout-plan-omits-zero-source-zero-width-gaps () + "The projection plan must omit gaps that cannot install a property." + (ekp-tests--with-clean-state + (let ((plan (ekp-layout-plan + "中文 mixed paragraph keeps natural gaps off the hot path." + 480))) + (cl-loop + for line across (ekp-layout-plan-lines plan) + do + (cl-loop + for gap across (ekp-layout-line-gaps line) + do + (should + (or (< (ekp-layout-gap-source-start gap) + (ekp-layout-gap-source-end gap)) + (> (ekp-layout-gap-target-pixel gap) 0)))))))) + +(ert-deftest ekp-test-layout-plan-records-discretionary-hyphen () + "A chosen Latin discretionary break must be explicit in the core plan." + (ekp-tests--with-clean-state + (let* ((plan (ekp-layout-plan + "extraordinary hyphenation demonstration paragraph" 15)) + (line (seq-find + (lambda (candidate) + (eq (ekp-layout-line-break-kind candidate) 'hyphen)) + (append (ekp-layout-plan-lines plan) nil)))) + (should line) + (should (ekp-layout-line-hyphen-p line)) + (should (= (ekp-layout-line-break-source-start line) + (ekp-layout-line-break-source-end line)))))) + +(ert-deftest ekp-test-public-string-renderer-consumes-layout-plan () + "The public formatter must render the shared semantic plan." + (ekp-tests--with-clean-state + (let ((calls 0) + (original (symbol-function 'ekp-layout-plan))) + (cl-letf (((symbol-function 'ekp-layout-plan) + (lambda (string width) + (cl-incf calls) + (funcall original string width)))) + (should (stringp + (ekp-pixel-justify + "Shared plans keep the string and buffer renderers aligned" + 24))) + (should (> calls 0)))))) + (ert-deftest ekp-test-gaps-between-brute-force () "`ekp--gaps-between' must equal naive counting." (ekp-tests--with-clean-state diff --git a/tests/run-c-resize-evaluator.sh b/tests/run-c-resize-evaluator.sh new file mode 100755 index 0000000..f344fec --- /dev/null +++ b/tests/run-c-resize-evaluator.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# Evaluate task032 against the frozen portable baseline. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +GOAL="$ROOT/.omx/goals/performance/c-resize-latency" +BASELINE_ROOT="$GOAL/baseline/source" +RAW="$GOAL/raw" +ROUNDS=${EKP_RESIZE_ROUNDS:-4} + +if test -n "${EMACS:-}"; then + EMACS_BIN=$EMACS +elif command -v emacs >/dev/null 2>&1; then + EMACS_BIN=$(command -v emacs) +elif test -x /Applications/Emacs.app/Contents/MacOS/Emacs-arm64-11; then + EMACS_BIN=/Applications/Emacs.app/Contents/MacOS/Emacs-arm64-11 +else + printf '%s\n' "resize-evaluator: Emacs executable not found" >&2 + exit 2 +fi + +test -f "$BASELINE_ROOT/ekp.el" +test -f "$BASELINE_ROOT/ekp-buffer.el" +test -f "$BASELINE_ROOT/ekp_c/ekp.dylib" + +make -C "$ROOT/ekp_c" clean all PROFILE=portable +mkdir -p "$RAW" +BASELINE_JSONL="$RAW/baseline.jsonl" +CANDIDATE_JSONL="$RAW/candidate.jsonl" +REPORT="$GOAL/latest-report.json" +: >"$BASELINE_JSONL" +: >"$CANDIDATE_JSONL" + +run_round() +{ + code_root=$1 + label=$2 + round=$3 + output=$4 + EKP_RESIZE_LABEL=$label \ + EKP_RESIZE_ROUND=$round \ + EKP_RESIZE_OUTPUT=$output \ + "$EMACS_BIN" -Q --batch -L "$code_root" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-c-resize-evaluator.el" +} + +round=1 +while test "$round" -le "$ROUNDS"; do + if test $((round % 2)) -eq 1; then + run_round "$BASELINE_ROOT" baseline "$round" "$BASELINE_JSONL" + run_round "$ROOT" candidate "$round" "$CANDIDATE_JSONL" + else + run_round "$ROOT" candidate "$round" "$CANDIDATE_JSONL" + run_round "$BASELINE_ROOT" baseline "$round" "$BASELINE_JSONL" + fi + round=$((round + 1)) +done + +EKP_RESIZE_MODE=compare \ +EKP_RESIZE_BASELINE_JSONL="$BASELINE_JSONL" \ +EKP_RESIZE_CANDIDATE_JSONL="$CANDIDATE_JSONL" \ +EKP_RESIZE_REPORT="$REPORT" \ + "$EMACS_BIN" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-c-resize-evaluator.el" + +"$ROOT/tests/run-tests.sh" "$EMACS_BIN" +"$EMACS_BIN" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-fuzz.el" +"$ROOT/tests/check-release.sh" + +printf '%s\n' "resize-evaluator: performance, parity, ERT, fuzz, and release gates pass" diff --git a/tests/run-tests-isolated.sh b/tests/run-tests-isolated.sh index a62db69..5c453aa 100755 --- a/tests/run-tests-isolated.sh +++ b/tests/run-tests-isolated.sh @@ -10,7 +10,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" TESTS=$( "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ -l "$ROOT/tests/ekp-tests.el" \ - -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-buffer-tests.el" \ -l "$ROOT/tests/ekp-gui-tests.el" \ -l "$ROOT/tests/ekp-c-tests.el" \ --eval '(dolist (test (ert-select-tests "^ekp-\\(?:test\\|[[:alnum:]-]+-test\\)-" t)) @@ -21,7 +21,7 @@ for test_name in $TESTS; do echo "isolated ERT: $test_name" "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ -l "$ROOT/tests/ekp-tests.el" \ - -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-buffer-tests.el" \ -l "$ROOT/tests/ekp-gui-tests.el" \ -l "$ROOT/tests/ekp-c-tests.el" \ --eval "(ert-run-tests-batch-and-exit '$test_name)" diff --git a/tests/run-tests.sh b/tests/run-tests.sh index c187485..25a2acc 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -10,7 +10,7 @@ case "$ORDER" in "") exec "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ -l "$ROOT/tests/ekp-tests.el" \ - -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-buffer-tests.el" \ -l "$ROOT/tests/ekp-gui-tests.el" \ -l "$ROOT/tests/ekp-c-tests.el" \ -f ert-run-tests-batch-and-exit @@ -18,7 +18,7 @@ case "$ORDER" in --random-order) exec "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ -l "$ROOT/tests/ekp-tests.el" \ - -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-buffer-tests.el" \ -l "$ROOT/tests/ekp-gui-tests.el" \ -l "$ROOT/tests/ekp-c-tests.el" \ -l "$ROOT/tests/run-tests-random-order.el" \