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-*.
This commit is contained in:
Kinneyzhang 2026-07-30 01:07:25 +08:00
parent fd1210e557
commit 83352c4571
56 changed files with 10704 additions and 2221 deletions

View File

@ -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

View File

@ -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`

View File

@ -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 6070 ms.](../phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md)

View File

@ -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.

View File

@ -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 6070 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.

View File

@ -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
3385 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.

View File

@ -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.

View File

@ -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 3385 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.

View File

@ -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.

View File

@ -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 164px 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 164px 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 164px 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.

View File

@ -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.

View File

@ -0,0 +1,55 @@
# Issue: C Resize Latency 2026-07-29
## issue020 [ ] C-backed resize reflow still takes roughly 6070 ms
- **Status:** Developer verification complete; user confirmation pending.
- **Summary:** Resizing with the C dynamic module enabled was observed at
roughly 6070 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.

View File

@ -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`

View File

@ -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 164px 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`.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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 3385 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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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 164px 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.

View File

@ -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 164px 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 164px 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 6070 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.

View File

@ -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.

View File

@ -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,0008,000-character adversarial benchmark, the 8,000-character cases

View File

@ -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*.

View File

@ -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

View File

@ -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
```

View File

@ -44,6 +44,9 @@
- `task015` 已完成终局清理与门禁:删除九个死 accessor 和依赖私有
substring-filter API 的调用;最终独立代码审查 `APPROVE`、架构审查
`CLEAR`,全部仓库内 issue/task 均已闭环。
- `task017` 已将当前编辑器集成模块直接改名为 `ekp-buffer.el`
下文的 `ekp-region.el``ekp-region-*` 仍保留为审计基线的历史
证据,不代表当前加载入口。
- 下文保留 `main@29cef97` 的原始证据与判断,不把历史问题改写成从未发生。
## 结论先行

2011
ekp-buffer.el Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

333
ekp.el
View File

@ -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."

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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 3385 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.

View File

@ -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
164 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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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 3385 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.

View File

@ -0,0 +1,53 @@
# C Resize Hot-Path Ownership
## Context
The reported 6070 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 6070 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.

125
readme.md
View File

@ -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

View File

@ -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)` 不会加载它。
## 致谢

View File

@ -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

2310
tests/ekp-buffer-tests.el Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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)

View File

@ -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

72
tests/run-c-resize-evaluator.sh Executable file
View File

@ -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"

View File

@ -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)"

View File

@ -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" \