Canonical document editing foundation

September 11, 2026 ยท View on GitHub

DocumentEdit owns one exact canonical document lifetime and a monotonically increasing live revision. It applies a bounded list of insert, remove, move and existing-value replacements to staged source, then runs the same strict Document validator before publishing one history step. No intermediate model validation blocks an explicit repair of a surviving reference in the same transaction. Stale identities, ambiguous node IDs, cycles, invalid final references or exceeded limits leave source, live identity, history and the caller's receipt unchanged. Diagnostics describe the staged source that failed; they do not indicate a published change.

Insertion targets an existing parent ID and a zero-based child index. Removal and movement cannot remove or move the root node. A move's destination index is evaluated after removal, and moving a node to its existing index is a source no-op. Replacement selects an existing JSON value relative to a stable node ID, or relative to the whole document when node is empty; it uses the existing strict JSON-pointer policy. Replacements can repair document-level aliases, timelines or other references explicitly. The document ID must remain the same throughout an editor lifetime. Whole-value replacements must still produce a complete valid final root; they do not bypass validation.

Source is JSONC with the existing strict number/Unicode/duplicate-key/trailing-data policy. The editor preserves bytes outside the affected spans and keeps BOM/CRLF offsets correct. Leading, trailing and between-child comments belong to the containing array and remain at that source location when a subtree moves. Only the subtree's JSON value bytes, including its internal comments and extensions, move. One required structural comma is removed independently of surrounding trivia. Inserted fragment comments outside its parsed value span are ignored, matching existing ReplaceValue behavior; they cannot swallow a parent comma or closing brace. This is a defined source-preservation contract, not arbitrary trivia attachment to a visual tree node.

History retains shared immutable validated documents. Adjacent before/after states share the same source instead of copying it twice. An edit prepares a candidate history vector before publication and drops redo only when the new branch is accepted. Byte-identical operation groups add neither revision nor history. Undo and redo change the cursor and increment the live revision without allocation or reparsing. The currently returned Document pointer is read-only and borrowed until the next mutating call or destruction. Callers serialize use; there are no renderer, filesystem, engine, or foreign callbacks.

Limits bound source bytes, all retained history source bytes (including current and redo), state count, operation count, and a separate conservative working-source budget for retained source plus staging/candidate/BOM/fragment copies. Limits are checked before their corresponding source/history publication. They describe source storage, not all JsonCpp/model/container allocation overhead. Allocation exceptions returned through the API preserve editor state. No universal process-termination recovery is claimed for upstream debug-STL/JsonCpp noexcept allocation paths. MSVC tests separately deny allocation across successful undo/redo; Linux tests sweep allocation failure through candidate preparation.

tools/tests/ui_document_edit.py compiles the real Document, DocumentEdit, State, Vector, Motion and Presentation sources with pinned JsonCpp/libtess2 sources read-only. It supports Windows Clang, MSVC debug and GCC sanitizers, and compiled behavioral mutations. Token-exhaustion qualification injects failure only at the lifetime allocator's return boundary while retaining the actual editor caller methods. It creates no window, renderer or editor shell and performs no OS input.

The full editor still needs hierarchy/selection/property panels, vector and timeline tooling, integration of the prepared canvas publication below with completed native text-owner retirement, filesystem save/recovery, and visible load/edit/undo/save/reload acceptance. Those must use the engine's existing Runtime and EngineHost renderer; this portable foundation is not a second preview implementation and does not complete the M2 editor gates.

Prepared source and runtime publication

DocumentEdit::PrepareEdit and PrepareUndo stage history without changing the live source. Runtime::PrepareDocument joins that exact prepared owner and origin with a nonreused runtime lifetime and mutation revision, copies the supplied path/state/viewport/time, then loads and lays out a separate context through the existing Runtime, RmlUi, font host and retained controls. It submits no draw or application action. A source no-op checks the same identities and preserves history revision, canvas and cached path. Only one outstanding candidate is admitted per source owner and runtime; prepared values have no independent live editing or native authority.

Every public Runtime mutation is checked at entry, including rejected input, state changes, Frame, snapshot restoration, native model operations and TakeActions. An intervening mutation invalidates an older preparation even if it leaves the pixels unchanged. The checked publication joins the exact prepared source object, live owner, runtime revision, native vacancy, and candidate association again. It then swaps history, Runtime Impl and the preconstructed receipt without allocation, foreign calls, diagnostics or destruction. Lifetime/revision exhaustion refuses; tokens never wrap or reuse.

The RetainedUI envelope adds the registered view lifetime, cached source and path, and actual renderer/language generations. Cached bytes must match both the history origin and loaded Runtime before preparation. Successful publication installs source, path, canonical state and canvas in the same callback-free interval. Unchanged-output guarantees apply to returned refusal; diagnostics from preparation may describe the rejected candidate. Native binding is checked from the stored model rather than current focus/eligibility. A live native owner refuses preparation and publication; the caller must complete the existing exact retirement protocol before requesting a fresh identity. This layer does not pretend to reverse an already completed native retirement when later preparation fails.

Prepared destruction is safe after the original source/view/runtime owner dies. Successful publication leaves the old context and old metadata owned by the prepared value until explicit destruction. All borrowed Document, element, model and text references from the old Impl expire at publication, even if their storage remains retained for cleanup. Keeping the Runtime wrapper address stable does not preserve those borrowed references.

A resource refresh invalidates and closes outstanding prepared contexts before resetting shared host resources. Reentrant destruction during prepared work or constrained input layout revokes the registered view immediately and retains its storage until the original callback returns. The outer RetainedUI service scope drains pending destruction and shutdown on normal return, refusal and exception unwinding; a later safe entry is only a backstop. The shared host and a standalone Runtime must still outlive their own active member/callback stacks. This local deferred cleanup is not a general engine fatal-teardown barrier: outer resource destruction must respect the service's ownership obligations. Legacy Frame, ordinary source reads and other existing Host paths do not yet acquire this new callback scope; general reentrant destruction/reset from those old callbacks remains prohibited by the existing Host lifetime contract. A following bounded increment must reproduce and guard those paths rather than infer coverage from the constrained-popup tests.

Input layout and preparation diagnostics

An open choice with placement bounds refreshes authored motion and the original public Rml document layout before the next hit or acceptance decision, under that context's clock and an exact Runtime callback scope. Reentrant mutation invalidates the scope. The owned path checks after foreign translation/property/layout boundaries before proceeding, and stale or unpainted opening geometry cannot accept a row. Frame uses the normal settling path; the extra input-time layout is restricted to an open constrained popup. Its performance budget still needs measurement with the real editor/menu workload.

Preparation captures warning/error/failure diagnostics for the exact candidate context and owning thread. Nested work in another Rml context does not taint this candidate merely because it shares the global interface. Failed font metrics, host inputs, invalid viewport parameters or observed candidate warnings refuse publication. This checks CPU source/model/layout availability; it does not claim that future lazy vector compilation, glyph material creation, GPU allocations or a void Host Draw have already succeeded. Real engine rendering and renderer-failure recovery remain separate acceptance work.

The source/history budget covers the DocumentEdit staging and retained source contract above. Runtime candidates and engine envelopes add a bounded source/path copy and a second normal Rml context; the source budget is not a total process-memory guarantee. Upstream noexcept allocation termination remains outside recoverable returned-error guarantees. Successful publication, exact no-op publication, candidate observations on refusal and deferred scalar retirement are tested with allocation denial where their contracts require it.

Qualification uses actual Runtime/Rml and extracted first-party RetainedUI methods with a counted Host, plus the real portable history model on MSVC/Clang/GCC sanitizers. It does not activate a window/native provider, observe hardware input, submit real GPU work, or complete the editor shell/save/packaging gates. The full product still requires one actual engine canvas with hierarchy/selection/property/vector/timeline authoring, durable file save/recovery, and visible edit/undo/save/reload acceptance.

The opaque retainedUIPreparedEdit_t* is a caller-owned preparation allocation. It may be published or rejected while live, including after its original view/source owner dies, and must then be explicitly destroyed. Passing it to Publish after DestroyPreparedEdit returns is invalid borrowed-handle use; pointer registration is not a token that prevents allocator ABA. A consumed but still-live preparation does safely refuse repeated publication. The caller must clear its pointer after destruction.

Candidate SetState or layout refusal currently may return without appending a new Diagnostic; observed parser/font/context diagnostics are retained where available. The absence of a new diagnostic never indicates successful publication. Richer error propagation remains an editor-usability follow-on.

The regular Meson build runs both the actual Runtime canvas regression and the registered-view regression. The latter generates its service method include from the current RetainedUI.cpp through ui_document_view_projection.py, using the same extraction as the standalone MSVC qualification runner. It links the real Runtime/history/Rml implementation and counts engine resources at the boundary; the test is portable and does not launch the engine. The separate MSVC mutation runner requires a passing, unchanged source-bound baseline and checks each deliberately altered implementation by compiling and executing it.