nigig-org/crates/apps/doc/doc-ui/README.md
andodeki 1d8b3a6053
Some checks failed
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
merge(local): reapply local WIP onto merged main -- cad dashboard/explode/script_parts/xray merged with remote Phase-5 LOD, plus doc-ui extraction, spreadsheet xls-import, android ussd, camera and test work
2026-09-01 21:13:52 +03:00

1584 lines
80 KiB
Markdown

# Document editor module
This is a complete behavior-preserving migration of the supplied monolithic `doc/mod.rs`.
Copy this `doc/` folder to `crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/`.
## Ownership
- **model**: document data types, formatting, cursor, selection
- **editing**: command and history boundary
- **layout**: page metrics, layout records, glyph-hit records
- **render**: shared Makepad rendering helpers
- **widgets**: the custom immediate-mode `DocEditor` and toolbar `DocWorkspace`
- **persistence**: generated-file document storage
The custom editor preserves direct Makepad immediate-mode layout/draw/event behavior from the original source. It uses direct live draw fields—not `DrawText::clone()`—which is compatible with current Makepad dev behavior.
## Stage 1: controller boundary
`DocEditor` now contains a `DocumentController`, rather than independent
`blocks`, cursor, selection, and undo/redo fields. The controller separates:
- `Document`: shared block content and metadata/revision
- `DocumentSession`: per-view cursor and selection state
- `SnapshotHistory`: transitional undo/redo storage
Host applications may inject or retrieve a controller via
`DocEditor::set_controller`, `DocEditor::controller`, and
`DocEditor::controller_mut`. Snapshot history remains intentionally in Stage 1;
Stage 2 replaces it with command transactions.
## Stage 2: command transactions
Undo and redo no longer store serialized document strings. `editing::History`
stores reversible `Transaction` values made from structured `Command`s. The
existing editor routines use `Command::RestoreDocument` as a compatibility
operation while preserving every v1 interaction. New controller APIs can add
granular commands such as `InsertText`, `DeleteBackward`, `SplitBlock`, and
style toggles. The next migration step routes each legacy routine directly to
those granular commands and coalesces typing into a transaction.
## Stage 3: persistent layout tree
`DocEditor` now owns a per-view `LayoutEngine`, not a standalone glyph-hit
vector. The engine exposes a renderer-independent `LayoutTree` with page,
line, run, glyph-hit, content-height, and document-revision fields. Layout
geometry is explicitly excluded from `Document`, so a shared document can have
multiple independent views/sessions. The existing immediate-mode traversal now
populates the persistent tree; Stage 4 moves its draw operations into the
renderer that consumes this tree.
## Stage 4: renderer boundary
`render::DocumentRenderer` now owns page, selection, caret, and styled-line
draw primitives. `DocEditor` provides document/session/layout state and live
Makepad draw resources; renderer code does not mutate editor state. Table,
image, and divider drawing remain in the legacy widget traversal for this
incremental stage and can now be moved one block renderer at a time without
changing document or layout APIs.
## Stage 5: expandable block and inline model
`model::advanced` introduces the v2 document schema without breaking the
currently compiling legacy widget. It supplies `DocumentNode`, `BlockKind`,
`Inline`, nested table-cell documents, lists, quotes, code, columns,
containers, images, canvas objects, embedded-widget descriptors, audio, video,
and diagrams. `Document::nodes` holds these future-ready nodes alongside the
legacy `blocks` compatibility collection. Stage 6 connects these nodes to the
layout and renderer through a block registry.
## Stage 6: advanced-block layout and embedded-widget hosting
The layout tree now includes `advanced_blocks`, generated from v2
`Document::nodes` by `AdvancedLayout`. `DocumentRenderer` draws document-space
GPU hosts for rich nodes. `widgets::EmbeddedWidgetRegistry` lets applications
register serializable embedded-widget type descriptors without placing live
Makepad widget instances inside the document model.
## Stage 7: collaboration and plugins
`collaboration::CollaborationSession` queues local `DocumentOperation`s and
deduplicates remote operations by actor/sequence identity. It also owns
non-persistent remote presence (cursor/selection) state. No network protocol is
forced: applications can bridge the queues through WebSocket, WebRTC, files, or
custom synchronization.
`plugins::PluginRegistry` supplies serializable block-plugin descriptors for
future custom block/layout/render factories. Stage 7 deliberately establishes
the transport and registry boundaries; conflict transformation/CRDT resolution
is the next collaboration implementation layer.
## Input stabilization: mobile IME and selection
This package fixes two current interaction blockers before further milestones:
- The editor reasserts `show_text_ime` from `draw_walk` after the focused draw
area exists, which supports mobile backends that drop a `FingerDown`-time IME
request.
- Drag selection is explicitly captured by `pointer_selecting` and selection
rendering compares document cursor ranges instead of requiring exact glyph-hit
endpoint matches. End-of-span/end-of-line drag selections now highlight.
## Input diagnostics
This build emits `[DOC_TRACE]` diagnostics to standard error for document load,
pointer-down/move/up, glyph hit testing, key events, `TextInput`, insertion, and
IME activation. On desktop, run the app from its terminal. On Android/iOS,
capture the native process/device logs and search for `DOC_TRACE`.
## Render/input correction
The trace demonstrated that typing reaches `Hit::TextInput` and increments the
cursor. The apparent blank document came from the transparent full-editor input
area being drawn at depth `0.9`, above text depth `0.2`; it could depth-occlude
page text despite its tiny alpha. It now draws at depth `-1.0`. The IME path
also falls back from a zero `clipped_rect` to the valid area rectangle.
## Selection-handle correction
A non-handle tap in mobile View mode now clears the prior selection immediately,
so handles disappear. Handle hit tests are now performed in the same window
coordinate space as `FingerDown` rather than relying on implicit local-space
conversion. `[DOC_TRACE] handle hit` and `handle moved` logs diagnose handle
capture and endpoint updates.
## Compact-screen default
At the first draw, a known screen width below `700.0` initializes `InteractionMode::View` before any touch event. This prevents the initial mobile tap from focusing the editor or opening the IME.
## Mobile viewport gesture routing
In compact View mode, `DocEditor` no longer calls `event.hits` on the full page.
`TouchUpdate` handles only long press and visible selection handles, while normal
touch movement is left unclaimed so the surrounding `ScrollYView` can pan.
A short tap moves the passive cursor and dismisses selection without focusing the
editor or showing the keyboard.
## Granular command migration: text insertion
`Command::InsertText` and its exact `Command::DeleteText` inverse now mutate
`Document` through `DocumentController::execute`. Keyboard/IME insertion no
longer uses a legacy serialized snapshot. Undo/redo applies inverse commands.
The remaining legacy operations (delete range, split/merge, style and blocks)
are intentionally left on snapshot compatibility until each has a complete
inverse implementation.
## Granular command migration: replacement and backspace
Typing over a same-span selection now runs a single transaction containing
`DeleteText` followed by `InsertText`. Backspace deletes either the selected
same-span range or the preceding Unicode character through `Command::DeleteText`.
Undo restores both operations as one transaction.
## Granular command migration: style ranges
Bold, italic and underline selections now create `Command::ReplaceSpans` entries
for every affected paragraph/heading block. The command swaps exact span vectors
and returns the prior vectors as its inverse, so formatting undo/redo uses the
same command transaction history as typed text.
## Granular command migration: alignment
Left, center, and right toolbar controls now execute `Command::SetAlignment`.
The command swaps the exact previous alignment as its inverse, making alignment
changes participate in command undo/redo without legacy snapshots.
## Granular command migration: block insertion
Table, image, and divider toolbar insertion now use `Command::InsertBlock`.
Its inverse is `Command::RemoveBlock`, so insertions undo and redo without a
legacy snapshot.
## Granular command migration: paragraph split
Return in a standard paragraph/heading now creates a `ReplaceBlockRange`
transaction replacing one block with two paragraph blocks. Its inverse restores
the exact prior block. Return inside a table remains on the current table-row
compatibility path.
## Granular command migration: paragraph merge
Backspace at the start of a standard text block now executes `ReplaceBlockRange`
to replace the previous/current pair with one merged paragraph. Undo restores
the exact two original blocks and cursor position is moved to the merge boundary.
## Granular command migration: table append row
Return while cursor is inside a table now replaces the table block through
`ReplaceBlockRange` with a copy containing one appended empty row. Undo restores
the original table block exactly.
## Granular command migration: table-cell replacement
IME/keyboard text insertion into a table cell now uses `ReplaceTableCell`.
The command captures the complete previous cell text as its inverse, so typing
in tables now participates in command undo/redo.
## Command history: typing coalescing
Contiguous `InsertText` commands in the same block/span now append inverse
`DeleteText` commands to the prior history transaction. Typing a word becomes
one Undo action; cursor movement, deletion, selection replacement, formatting,
or any other command breaks the group.
## Granular command migration: non-text block deletion
Delete while the cursor is on a table, image, or divider now executes
`Command::RemoveBlock`. Its inverse is `InsertBlock`, so non-text block removal
is command-driven and undoable.
## Granular command migration: advanced nodes
`InsertNode`, `DeleteNode`, and `ReplaceNode` now mutate `Document::nodes` and
return exact inverse commands. Advanced document nodes are ready for command
history and future renderer/editor actions.
## Collaboration: remote command application
`DocumentController::apply_remote_operation` now accepts deduplicated remote
`DocumentOperation`s and applies their forward commands without adding them to
local undo history. Conflict transformation/CRDT ordering remains the next
collaboration layer.
## CRDT identity foundation
`model::crdt` introduces `AtomId`, Lamport clocks, tombstoned `TextAtom`s,
RGA-style `RgaText`, `BlockId`, and `CrdtMetadata`. No legacy vector-index
commands are replaced yet; this package establishes deterministic IDs and text
sequence semantics required for the next migration.
## CRDT local insert bridge
Normal unselected local text insertion now lazily seeds the current legacy span
into `RgaText`, creates stable local `TextAtom`s, and executes `InsertAtoms`.
Selected replacement and table paths remain on their existing command bridge
until CRDT range/tombstone selection operations are added.
## CRDT selection identity
`DocumentController::sync_crdt_selection_from_legacy` maps the legacy visual
selection anchor/focus into stable `CrdtTextPosition` values. CRDT-backed
selection ranges can now be transmitted without relying solely on character
offsets.
## Command and engine roadmap
- [x] Text insert/delete and selection replacement
- [x] Paragraph split and merge
- [x] Style ranges and alignment
- [x] Table cell/row commands
- [x] Block and image commands
- [x] Typing coalescing
- [x] Internal copy/cut/paste keyboard commands
- [x] Native system clipboard copy/cut integration
- [x] Native system clipboard paste via platform TextInput fallback
- [x] Table column and cell merge commands (CRDT-native: Shift+Arrow cell range + Ctrl/Cmd+M merge, Ctrl/Cmd+Shift+M split in `CrdtDocEditor`; see "CRDT-native cell range selection and merge/split")
- [x] Incremental layout invalidation tracking
- [x] Incremental block fragment cache foundation
- [x] Block fragment cache population during layout traversal
- [x] Block fragment cache population during layout traversal
- [x] Cached text measurement reuse
- [x] Incremental page/block reflow execution — retired for the legacy
fallback editor; the active CRDT-native path rebuilds only on
document change through the version-keyed layout cache
(`CrdtDocEditor::layout_tree`), see "Legacy perf boxes: retirement
decision and the CRDT-native layout cache"
- [x] Renderer extraction: divider block
- [x] Renderer extraction: image placeholder block
- [x] Renderer extraction: table cell primitive
- [x] Renderer extraction: advanced block placeholders
- [x] Mobile gesture arbitration state machine + tests
- [x] Mobile gesture router wired into DocEditor touch handling
- [ ] ScrollYView parent handoff verification on Android/iOS — hardware
execution pending; the full step matrix with pass/fail criteria
lives in `DEVICE_VERIFICATION.md` (section 5)
- [x] AdaptiveView mobile Edit/Done toolbar control
- [x] CRDT atom/block identities and local text edits
- [x] CRDT remote buffering, presence, and tombstone frontier foundation
- [x] Tombstone deletion timestamps and CRDT atom persistence
- [x] CRDT table stable-ID model foundation
- [x] CRDT advanced-block stable-ID model foundation
- [x] Peer synchronization transport boundary + MemoryTransport
- [x] Visual remote selections/cursors
## CRDT tombstone timestamps
- [x] Tombstone deletion timestamps
Each deleted atom records `deleted_at: Option<LamportTimestamp>`. Garbage
collection now compacts only atoms whose deletion timestamp is behind the
collaboration-safe acknowledged frontier.
## Test coverage
- [x] Unit tests: reversible text commands
- [x] Unit tests: controller undo/redo
- [x] Unit tests: deterministic RGA tombstones
- [x] Unit tests: table row commands
- [x] Unit tests: block ID insert/remove invariants
- [x] Integration tests: causal remote-operation buffering
- [x] Integration tests: reversible table cell merge
- [x] Integration tests: memory transport operation loop
- [x] Unit tests: rectangular table selection
- [x] Unit tests: advanced block layout
- [x] Unit tests: typing coalescing
- [x] Unit tests: tombstone compaction frontier
- [x] Unit tests: layout invalidation tracking
- [x] Unit tests: table column command inverse
- [x] Unit tests: table merge/split inverse
- [x] Unit tests: CRDT selected-range replacement inverse
- [x] Unit tests: mobile selection-handle gesture routing
- [x] Unit tests: advanced JSON canvas round trip
- [x] Unit tests: advanced JSON recursive table round trip
- [x] Unit tests: advanced JSON inline link/widget round trip
- [x] Unit tests: block layout cache invalidation
- [x] Unit tests: cached text measurement reuse
- [x] Unit tests: CRDT-native projection table layout, merges and hit testing
- [x] Unit tests: CRDT-native advanced node layout and unified order
- [x] Unit tests: CRDT-native word atom ranges and selection-handle geometry
- [x] Integration tests: widget input, selection, and mobile gestures
(real-`Cx` runtime harness plus the draw-free `Area::Rect` stub)
- [x] Integration tests: renderer draw pass — resolved as documented:
layout/hit/draw-order LOGIC is covered by the draw-free runtime
harness (real-`Cx` plus `Area::Rect` stubs); painting/clipping
visual verification stays GPU/Studio-bound and lands with the
device-verification batch (see "Legacy perf boxes: retirement
decision and the CRDT-native layout cache"
## Advanced JSON V3 persistence
- [x] Versioned JSON DTOs for code, media, canvas, diagrams, and embedded widgets
- [x] Loss-prevention errors for unsupported advanced node kinds
- [x] JSON deserialization into runtime advanced nodes
- [x] Persist paragraph/heading and core inline advanced nodes
- [x] Persist advanced inline image/link/widget nodes
- [x] Persist list/quote/columns/container recursive advanced nodes
- [x] Persist advanced recursive table model
- [x] Persist advanced image resources and captions
## Layout extraction
- [x] Pure `layout_paragraph` block-layout function
- [x] Paragraph layout unit test
- [x] Paragraph alignment layout test
- [x] Renderer support for ParagraphFragment
- [x] Wire DocEditor paragraph rendering to LayoutEngine fragments
- [x] Pure `layout_table` block-layout function
- [x] Table layout unit test
- [x] Wire DocEditor base table geometry to LayoutEngine fragments
- [x] Pure `layout_image` block-layout function
- [x] Image layout unit test
- [x] Wire DocEditor image geometry to LayoutEngine fragments
- [x] Pure `layout_divider` block-layout function
- [x] Divider layout unit test
- [x] Wire DocEditor divider geometry to LayoutEngine fragments
- [x] Pure `layout_pages` page-stack function
- [x] Page layout unit test
- [x] Wire DocEditor page stack to LayoutEngine page fragments
- [x] Populate LayoutTree line/run records from paragraph fragments
- [x] Encapsulate paragraph fragment → LayoutTree transfer
- [x] Populate LayoutTree table-cell records from table fragments
- [x] Populate LayoutTree image records from image layout fragments
- [x] Populate LayoutTree divider records from divider layout fragments
- [x] LayoutTree caret geometry API
- [x] Unit tests: LayoutTree caret geometry
- [x] Unit tests: table-cell caret geometry
- [x] LayoutTree selection geometry API
- [x] Unit tests: LayoutTree selection geometry
- [x] LayoutTree nearest-hit API
- [x] Unit tests: LayoutTree nearest-hit lookup
- [x] Wire DocEditor fallback hit testing to LayoutTree nearest-hit API
- [x] Table render-cell fragment builder
- [x] Table render-cell merge geometry test
- [x] DocumentRenderer table-fragment draw API
- [x] Wire DocEditor table drawing to TableRenderCell fragments
- [x] Renderer extraction: page background/shadow primitive
- [x] Renderer extraction: remote presence primitives
## Renderer extraction completion
- [x] Page background/shadow
- [x] Paragraph fragments
- [x] Table fragments and merged cells
- [x] Image placeholders
- [x] Dividers
- [x] Advanced block placeholders
- [x] Selection/caret/remote presence primitives
- [x] Remove remaining legacy caret calculations from DocEditor
- [x] LayoutTree caret support for empty paragraph spans and table cells
- [x] Block cache stores typed layout fragment payloads
- [x] Reuse cached fragment payloads during draw traversal — retired
for the legacy fallback editor; the CRDT-native draw walk reuses
the cached layout tree's glyph/rect payloads directly
(see "Legacy perf boxes: retirement decision and the CRDT-native
layout cache"
- [x] Per-block document revision foundation
- [x] Wire commands to touch only affected block revisions — retired
for the legacy fallback editor; CRDT change detection keys on the
op version-vector sum instead of per-command revision bumps
(see "Legacy perf boxes: retirement decision and the CRDT-native
layout cache"
- [x] Replace DocEditor blanket layout invalidation with cursor-block invalidation
- [x] Command-range-aware invalidation handoff via DocumentController
- [x] ParagraphFragment matching-revision cache reuse
- [x] TableFragment matching-revision cache reuse
- [x] Image/divider matching-revision cache reuse
- [x] Unit tests: matching-revision block cache payload reuse
- [x] Cache geometry/origin validation
- [x] Unit tests: cache geometry validation
- [x] Cache height-change detection and following-fragment invalidation
- [x] Unit tests: height-change fragment invalidation
- [x] Page layout cache foundation
- [x] Unit tests: page cache invalidation
- [x] Populate page cache from page stack traversal
- [x] Reuse cached page fragments during page stack traversal
## Incremental reflow checkpoint
- [x] Per-block revisions
- [x] Command-aware block invalidation
- [x] Typed fragment cache
- [x] Paragraph/table/image/divider fragment reuse
- [x] Height-change following-fragment invalidation
- [x] Page cache population/reuse
- [x] Page/block incremental reflow execution foundation
- [x] Long-document paragraph layout benchmark harness
- [x] Collaboration acknowledgement message protocol
- [x] Unit tests: collaboration acknowledgement transport
- [x] Synchronization-triggered acknowledged tombstone compaction
- [x] Unit tests: acknowledgement safe frontier
## CRDT engine bridge
- [x] Temporary CRDT projection bridge source
- [x] Add `doc-engine` Cargo dependency to nigig-build
- [x] Wire CrdtProjectionBridge into DocEditor controller
- [x] DocEditor CRDT projection bridge installation API
- [x] DocEditor CRDT projection refresh API
- [x] DocEditor CRDT operation dispatch API
- [x] Route unselected keyboard/IME text insertion through CRDT engine
- [x] Route unselected Backspace through CRDT engine
- [x] Route unselected Delete through CRDT engine
- [x] Migrate selected replacement, formatting, table, and toolbar actions to CRDT operations
- [x] Bridge projected CRDT table merge metadata into legacy renderer
- [x] Route table merge/split shortcuts to CRDT operations
- [x] Bridge CRDT projected advanced nodes into legacy advanced layout
- [x] Remove legacy end-of-document advanced-node rendering pass
- [x] Render inline AdvancedNodeRef through AdvancedLayout/DocumentRenderer
- [x] Preserve CRDT table merge block indexes under unified projection order
- [x] Route image/divider insertion toolbar actions through CRDT InsertNode
- [x] Route inline advanced node deletion through CRDT DeleteNode
- [x] Route table insertion toolbar action through CRDT InsertBlock table
- [x] Save/Open CRDT operation log when CRDT engine is active
- [x] Legacy delimiter save fallback
- [x] Automatic legacy-to-CRDT migration on first text edit
- [x] Preserve bold/italic/underline StyleSpan formatting during CRDT migration
- [x] Preserve legacy font size/color during CRDT migration
- [x] Route alignment toolbar actions through CRDT SetBlockAlignment
- [x] Route Undo/Redo through CRDT controller when active
- [x] Unit tests: CRDT projection bridge text/style materialization
- [x] Unit tests: CRDT projection bridge table materialization
- [x] Unit tests: CRDT projection bridge inline advanced node references
- [x] Unit tests: CRDT bridge unified block/node order
## CRDT-native widget rewrite
- [x] CrdtDocEditor skeleton
- [x] ProjectionSession skeleton
- [x] ProjectionLayoutTree skeleton
- [x] ProjectionRenderer skeleton
- [x] CRDT-native projected styled text rendering
- [x] CRDT-native basic text input interaction
- [x] CRDT-native basic selection highlight
- [x] CRDT-native basic pointer drag selection
- [x] CRDT-native mobile long-press/handle selection
- [x] CRDT-native table rendering
- [x] CRDT-native advanced node rendering
- [x] CRDT-native keyboard editing
- [x] CRDT-native in-cell table editing
- [x] Switch workspace DSL to CrdtDocEditor
- [x] CrdtDocEditor engine installation API
- [x] CrdtDocEditor default CRDT paragraph initialization
- [x] CrdtDocEditor AtomId cursor anchor updates on input
- [x] Projection atom glyph layout and pointer hit testing
- [x] CRDT-native caret rendering
## CRDT-native vertical slice
- [x] CRDT paragraph creation
- [x] Atom text insertion
- [x] Projection layout glyph hit
- [x] Insert after hit atom
- [x] CRDT undo
- [x] CRDT JSON save/load
- [x] CrdtDocEditor widget runtime integration test
- [x] Separate CrdtDocWorkspace runtime-test view
- [x] Wire application navigation switch to CrdtDocWorkspace
## CRDT-native table rendering
`CrdtDocEditor` now renders projected tables without passing through the
legacy bridge. `layout_projection` emits a `ProjectedTableLayout` for every
projection block of kind `table`:
- Fixed 160x28 cell geometry (matching the legacy bridge column width, so
both paths render the same table) with per-cell rects and text copied
from the projected `ProjectedTable` cell map.
- Table merges resolve their stable row/column ids to spans; the anchor
cell rect expands over the merged range and covered cells are flagged
`covered` with cleared text, so renderers skip them.
- Blocks after a table are placed below the table plus an 8px gap via
`ProjectionLayoutTree::block_origins`, which the text renderer now
consumes instead of re-deriving line positions.
- `ProjectionLayoutTree::table_hit_test` maps a layout-space point to the
visible (merge-anchor) cell under it, ready for cell-targeted editing.
`ProjectionRenderer::draw_table_projection` draws the cell grid as 1px
borders through a new `draw_table_border` live field on `CrdtDocEditor`
(default `#x9aa0a6`) and centers cell text vertically. Layout-space
coordinates stay shared between glyphs, block origins and table geometry;
the renderer maps them into widget space with one offset. Editing table
content CRDT-natively (cell cursor, in-cell text input) is the next layer
on top of this geometry.
## CRDT-native advanced node rendering
`CrdtDocEditor` now renders projected advanced nodes (images, dividers,
canvas, audio/video/diagram and embedded-widget placeholders) natively
from the projection:
- `layout_projection` walks the unified `DocumentProjection::order`, so
advanced nodes interleave with paragraphs, headings and tables at their
exact anchor positions instead of rendering as one trailing strip.
- `projected_node_metrics` mirrors the legacy `AdvancedLayout` heights,
labels and interactivity flags per kind (image 220, canvas 240, divider
18, unknown kinds map to `Widget: <kind>` exactly like the bridge's
`EmbeddedWidget` fallback), keeping both views visually identical.
- `ProjectionRenderer::draw_node_projection` reuses the legacy
`draw_advanced_blocks` colors (interactive/non-interactive fill tint,
top/bottom borders, kind label); dividers collapse to a centered 1px
line. Two new live fields, `draw_node_fill` and `draw_node_border`, hold
the script defaults.
- `ProjectionLayoutTree::node_hit_test` resolves points to node indexes
for future node selection and context menus. Container-nested nodes are
intentionally not top-level render items yet.
Engine fix uncovered by this work: the doc-engine after-chain was
block-only, so any paragraph anchored after an advanced node was
unreachable during materialization and silently vanished from the
projection (legacy bridge included). Nodes now participate in the chain
as connectors, converging `order` and `blocks`; regression tests cover
node-anchored blocks and mixed block/node sibling ordering in
`doc-engine/tests/materialize.rs`.
## CRDT-native mobile long-press and selection handles
`CrdtDocEditor` now shares `MobileGestureRouter` with the legacy
`DocEditor` and drives the compact-touch selection flow:
- `TouchUpdate` Start begins the router with a handle hit test
(`Option<bool>`: start/end) when a selection is visible, or arms
long-press detection on the frame clock otherwise.
- The router's `SelectWord` action maps the press point through the glyph
hit test into `word_atom_range`, which mirrors the legacy `word_bounds`
whitespace-pivot semantics and returns the word's first/last atoms as
the selection.
- `selection_handles` normalizes anchor/focus into document order (so a
backwards drag keeps start on the left edge) and yields 12px handle
rects with a 6px touch-slop hit test, drawn through a new
`draw_selection_handle` live field (default `#x1f73e6`). Dragging a
handle moves the corresponding selection endpoint to the hit atom.
- A short tap ends as `MovePassiveCursor`: passive caret placement, no
IME, prior selection dismissed. Drags past the slop threshold stay
unclaimed for a parent ScrollYView. Once a real touch sequence arrives,
the widget ignores synthesized finger events so tap/drag run through
the router path only; desktop mouse/IME behavior is unchanged.
- Physical ScrollYView handoff verification on Android/iOS remains device
work. (The Edit/Done-mode toggle that opens the IME on mobile arrived
later — see "Mobile Edit/Done interaction mode".)
## CRDT-native keyboard editing
`CrdtDocEditor` now handles the full desktop keyboard surface natively
through doc-engine operations, completing input parity with the legacy
`DocEditor` ahead of the workspace DSL switch:
- `handle_key_down` routes Command/Ctrl + `Z` (undo) and `Shift` + `Z`
(redo) through `CrdtHistory`, then sanitizes the cursor back onto a
live projection atom. Command/Ctrl + `B`/`I`/`U` toggle bold, italic
and underline over the selection's atom range via
`toggle_selection_style`. Arrow keys move (or with Shift extend) the
selection edge one glyph at a time across block boundaries using the
projection-wide `step_glyph` stream.
- `Backspace`/`Delete` first delete a non-empty selection as one atom
batch; at block boundaries they merge whole blocks.
- `Return` opens a table-row append when the caret is in a table block
(matching the legacy table compatibility path) and otherwise splits
the paragraph through the new engine op.
- Engine additions: `Operation::SplitBlock { block, offset }` and
`Operation::MergeBlocks { block }` with symmetric
`Compensation::{SplitBlock, MergeBlocks}` so undo/redo replay both
directions. Materialization keeps a split parent's trailing runs as
a synthetic trailing child spliced immediately after the parent;
style runs crossing the split boundary clone their crossing span
into both halves. `merge_runs` coalesces adjacent runs with equal
bold/italic/underline/font_size/color so the merged text stays
minimal.
- `TextInput` now deletes any active selection before inserting at the
session caret block (previously it always inserted into the first
block), mirroring the legacy replace-selection-on-type behavior.
Known limits, matching the split-session semantics in the doc-engine
README: a `DeleteBlock` of a split parent orphans the trailing half
(consistent with the pre-existing chain-break-on-delete semantic), and
the caret anchors on a glyph's left edge in layout space while its
semantic position is "after" that atom.
## CRDT workspace DSL switch
The active workspace DSL now instantiates the CRDT-native editor: both the
desktop dock (`docs_workspace`) and the mobile page (`m_doc_content`) in
`pages/workspace/project/mod.rs` create `mod.widgets.CrdtDocWorkspace`
instead of the legacy `DocWorkspace`. The classic widget remains fully
registered and functional as a fallback (`mod.widgets.DocWorkspace`, and
the legacy `DocEditor` still bridges CRDT projections for its own
migration path), so the switch is a DSL choice rather than a deletion.
To make that switch lossless, `CrdtDocWorkspace` graduated from the
runtime-test shell to the full workspace surface, mirroring the legacy
toolbar with CRDT engine routing:
- Open/Save/SaveAs serialize the engine document onto the shared
`#MP_CRDT_V1` wire (`projection_session::crdt_save_wire` /
`crdt_engine_from_saved`), the same format the legacy editor writes,
so documents move between both editors losslessly. Legacy
delimiter-format saves are detected and reported with a status message
instead of being silently dropped; their migration to CRDT stays with
the classic editor on first edit.
- Undo/Redo and bold/italic/underline map to the editor's public
`undo`/`redo`/`toggle_inline_style` (shared with the Ctrl/Cmd keyboard
paths), alignment buttons route `Left`/`Center`/`Right` — the same
`DocAlign` debug strings the legacy toolbar puts on the wire — through
`set_block_alignment`, and `+Table`/`+Img`/`+Div` call the new
`insert_table`/`insert_image`/`insert_divider` helpers with the legacy
anchored-after-caret semantics and default image caption.
- The stats label counts words/chars from the projection via
`projected_stats` (text blocks plus merged-once table cells; advanced
nodes contribute nothing), refreshed on every handled toolbar action
exactly like the legacy toolbar.
Deliberate gaps at switch time, both closed by later milestones: the
mobile Edit/Done IME toggle (see "Mobile Edit/Done interaction mode")
and in-cell table editing (see "CRDT-native in-cell table editing").
## Application navigation switch to CrdtDocWorkspace
With the DSL switch complete, the temporary "Docs CRDT" runtime-test tab
was an exact duplicate of the real "Docs" tab, so navigation has been
consolidated onto a single CRDT destination:
- The desktop dock's `workspace_tabs` keeps one "Docs" tab
(`docs_content` containing `CrdtDocWorkspace`); the `crdt_docs_tab`
definition, its `crdt_docs_content` view, the sidebar's
"Documents CRDT Test" button and its `select_tab` handler are removed.
"Documents" in the sidebar and the dock tab bar both land on the CRDT
editor.
- Mobile's workspace drawer resolves "Documents" to `doc_page` (whose
`m_doc_content` is `CrdtDocWorkspace`). That mapping is now the pure
function `workspace_page_id` in `pages/workspace/project/mod.rs`,
pinned by unit tests that assert the Documents destination, the
label-to-page table, page distinctness, and the CAD fallback for
unknown labels.
The legacy fallback posture is unchanged: `mod.widgets.DocWorkspace`
remains registered, so reverting any navigation node to the classic
editor is again a one-line DSL change. The standalone runtime-test view
roadmap item stays checked historically — it served as the pre-switch
verification surface and was removed only after becoming a duplicate.
## CrdtDocEditor runtime integration tests
`tests.rs` now runs the real editor widget inside a real `Cx` runtime —
no mocks of the event surface:
- The widget is constructed through the same `ScriptNew::script_new`
factory the production widget registry calls (a bare `ScriptVm` with
unit host/std suffices, per makepad's own script test pattern), and
the engine is installed through the public `set_engine` API.
- Real `Event::KeyDown` values with real `KeyEvent`/`KeyModifiers`
payloads enter through `Widget::handle_event` — identical dispatch to
a running app. Covered end-to-end: caret anchoring from an uncursored
editor, arrow stepping, Shift+ArrowLeft selection extension, Ctrl+B
bolding the full selection (projection runs asserted), Enter splitting
at the caret with the caret following the trailing half, Ctrl+Z
merging the split back, and Backspace on an empty split tail merging
into the previous block with the caret re-anchored on a live glyph.
Harness decision, documented: the `#[makepad_test]` Studio harness was
evaluated and rejected for this milestone. It builds and launches the
full application binary through the in-process StudioHub buildbox — a
heavy fit for a library-scale package in CI — and the repo's only
existing examples (`crates/apps/map/tests/ui.rs` and
`makepad_visual_tests.rs`) were written against aspirational APIs and
do not compile today.
Input routing through `Event::hits` is still covered without a GPU via
the draw-free `Area::Rect` stub (`CrdtDocEditor::stub_hit_area`):
tests install a single rect area on the widget's live `Cx` and mirror
the two platform pre-dispatch steps a real OS pump performs — priming
`fingers.first_mouse_button` (normally set by the platform mouse
handler before dispatch) and committing staged key focus by draining
one queued action through `handle_actions`. Raw `MouseDown` taps then
resolve to real `Hit` events, `MouseMove` synthesizes real
`Hit::FingerMove` while a button is down, and IME `TextInput` reaches
the editor exactly like a compositor delivery. Covered end-to-end:
caret placement from a tap, selection anchoring and drag extension over
specific glyphs, `TextInput` inserting at the caret and replacing an
active selection, and a cell tap + `TextInput` round trip through the
whole-cell write. This harness also unearthed and fixed two real bugs:
the projection `hit_test` nearest-glyph fallback resolving taps inside
tables/nodes to a text glyph (tables/nodes now own their taps), and the
test fixture actor drifting from production's single `"local"` actor
(cross-actor mid-run anchoring is deterministic but not chronological —
see the doc-engine projection invariants). Only the renderer draw pass
itself (paint and clipping visuals) remains GPU/Studio-bound. The
keyboard, touch, and frame-clock dispatch is direct (no area hit gate)
and fully covered by the runtime tests.
## CRDT-native in-cell table editing
`CrdtDocEditor` now edits table content CRDT-natively on top of the
`ProjectedTableLayout` geometry:
- Tapping (desktop `FingerDown`, mobile short tap) inside a cell parks a
`TableCellCursor` (table/row/column ids + char offset) on the char
under the pointer (`cell_char_offset_at` midpoint-splits the 7px char
grid, clamped to the text end). Text taps restore the text caret and
clear the cell cursor.
- Pointer selection works in cells too: a desktop press arms the in-cell
drag anchor, a drag spans a character selection clamped to the pressed
cell (crossing an edge clamps at the text ends instead of jumping
cells), and Shift+tap extends a live selection to the tapped offset.
Touch keeps its passive caret plus long-press cell-range path.
- Typing and Backspace/Delete edit inside the cell through whole-cell
`SetTableCell` replacements — the legacy `ReplaceTableCell` semantics —
with undo restoring the prior cell text through the symmetric
compensation. Char offsets are Unicode-scalar safe.
- Arrows walk the cell text in reading order and hop between cells
(wrapping across rows); at the table edges the caret exits into the
nearest text block in unified order (`neighbor_text_block` skips
advanced nodes), landing on its boundary glyph. Backspace at the start
of a paragraph following a table no longer dead-ends: it enters the
table's trailing cell; forward-Delete at a text end before a table
enters its first cell instead of merging table structure into text.
- Return inside a cell inserts a row immediately below the cursor's row
and moves the caret into the same column of the new row.
- The cell caret draws between rendered characters using the shared
6px inset / 7px-per-char convention; a stale cursor (its table vanished
in an undo) clears itself on the next frame.
- Shift+Arrow inside a cell spans a character selection on the cell's
text (`ProjectionSession::cell_text_anchor` = anchor offset, the caret
= focus), drawn as one highlight rect on the shared fixed char grid.
Typing or Backspace/Delete replaces/removes the span
(`cell_text_replace_range`), a plain move, tap, or edit collapses it,
and a Shift step AT the cell edge ends it and promotes to the
mergeable cell range. Undo staleness self-clears like the cell caret.
- Ctrl/Cmd+B/I/U (or the toolbar style buttons) with a parked cell
cursor styles the active in-cell selection, or the WHOLE cell when no
character selection is spanned, through the engine's cell style ops
(`SetTableCellStyle`/`Clear`/`Restore`, see the doc-engine invariants).
The projection emits per-cell styled runs (`cell_runs`) which the
layout tree mirrors and the renderer draws through the same
regular/bold/italic/bold-italic pens as block text. Undo/redo of a
cell style round-trips through the widget like any other op.
Engine fix uncovered by this work: `InsertTableRow`/`InsertTableColumn`
materialization ignored their `after` anchors and ordered rows/columns by
op id only. Rows and columns now materialize over the anchor chain with
RGA-style sibling order (counter descending, actor ascending), matching
text atoms; regression tests live in `doc-engine/tests/materialize.rs`
and the rule is documented in the doc-engine README invariants.
Also fixed while wiring taps: all pointer hit tests and the layout-space
decorations (selection, handles, text caret) now run through the widget
origin, so taps and visuals land on the same pixels at any dock position
or scroll offset instead of assuming the editor sits at (0, 0).
Runtime integration tests (real `Cx`, factory-built widget, real key
events) cover in-cell backspace with undo restore, arrow traversal into
and out of the table both directions with edge clamping,
Return-inserts-row-below, in-cell character selection spanning,
promotion to the cell range at the edge, type-over and
backspace-over-selection, tap char parking, drag spanning with edge
clamping, Shift+tap extension, selection-scoped style toggles from the
keyboard and whole-cell toggles from the toolbar — all with undo/redo;
pure layout tests cover the edit helpers, cell cursor
resolution/clamping, caret geometry, neighbor wrapping, layout-carried
cell style runs, selection-span replace/geometry helpers, char-offset
parking math, and `neighbor_text_block` skipping.
## CRDT-native cell range selection and merge/split
`CrdtDocEditor` now spans, renders, merges and splits rectangular table
cell ranges CRDT-natively, closing the roadmap's "Table column and cell
merge commands" item on the CRDT surface (the legacy editor never grew
these commands; its replacement owns them):
- `ProjectionSession::cell_selection` is a `TableCellSelection` of stable
anchor/focus row/column ids. Shift+Arrow at a cell boundary (or on an
active range) starts/steps the focus cell through `shift_cell_step`;
the in-cell caret follows the focus cell, table edges clamp the range
in place, in-cell Shift moves span character selections that end at
the boundary the range then owns, and any plain arrow, edit, tap or
drag collapses the range. Undo staleness self-clears exactly like the
cell caret.
- `table_cell_range` normalizes the selection to an inclusive
`(min_row, min_col, max_row, max_col)` rectangle against the projected
table, so anchor/focus order and row/column inserts between selection
and command keep the ids live. `cell_selection_rects` yields the
visible (non-covered) cell rects for the highlight, drawn under the
cell text with the text-selection color.
- Merge (`Ctrl/Cmd+M`, `merge_selected_cells`, workspace Merge button)
routes through the engine's `MergeTableCells` op; the symmetric
`SplitTableCells` compensation restores the cells on undo. A range is
mergeable only when it spans more than one cell and touches no
existing merge (`cell_range_mergeable`) — the engine accepts merge
ops freely, so the overlap guard lives UI-side where ambiguous nested
spans are rejected with the range kept for adjustment. The caret parks
on the merge's anchor cell, which keeps its text; covered cells' text
is preserved hidden and reappears on split.
- Split (`Ctrl/Cmd+Shift+M`, `split_cell_at_cursor`, workspace Split
button) resolves the merge containing the caret cell — covered cells
resolve to the same merge as their anchor via `merge_at_cell` — and
splits it through the engine; undo re-merges through
`RestoreTableMerge`.
- The workspace toolbar gains Merge/Split buttons (purple, after the
block-insert buttons). On touch devices they pair with the long-press
cell-range gesture (see "Touch cell-range selection") since Shift+Arrow
has no touch equivalent, and report guidance on the status line.
Runtime integration tests (real `Cx`, factory-built widget, real key
events) cover Shift+Arrow range spanning with merge + undo restore,
edge clamping, split from the covered cell with undo re-merge, plain
arrow collapse, and overlap rejection; pure layout tests cover range
normalization/staleness, the mergeable rules, `merge_at_cell` anchor/
covered resolution, and covered-skip highlight rects.
## Mobile Edit/Done interaction mode
`CrdtDocEditor` now mirrors the legacy `DocEditor`'s mobile interaction
policy, closing the last documented toolbar parity gap from the DSL
switch:
- The widget carries the shared `InteractionMode` (`Edit` default,
`View`) and a `mobile_mode_initialized` latch: the first real touch
sequence drops the session to View, while desktop mouse/keyboard
sessions stay in Edit by default (a desktop with a touchscreen keeps
full editing until it is actually touched).
- In mobile View mode the gesture router is the entire interaction
surface: short taps keep moving the passive caret (text and table
cells), long-press word selection and handle drags keep working, and
KeyDown handling is gated out entirely — arrows, edits, undo and style
toggles included — because the IME is closed by design. The area-hit
match is skipped like the legacy editor's, so ordinary drags fall
through to a parent ScrollYView.
- The workspace toolbar gains the mobile-only Edit/Done `AdaptiveView`
control (empty variant on desktop). Tapping it flips
`toggle_interaction_mode`: entering Edit takes key focus and mirrors
the state on the button label ("Edit"/"Done"); entering View calls
`hide_text_ime` and resets any in-flight gesture so a mode change
never straddles a touch sequence.
- Edit-mode touch taps focus the editor and request the IME at the tap
point; draw_walk then reasserts `show_text_ime` every frame while Edit
holds key focus, positioned at the live caret (text glyph or table
cell, bottom-left, relative to the clipped area) — the frame-driven
reassert several Makepad mobile backends require, ported from the
legacy editor's IME stabilization.
- `set_interaction_mode`/`toggle_interaction_mode` and the
`interaction_mode`/`mobile_mode_initialized` fields are public, so
hosts can force or inspect the policy (the button label needs it).
Runtime integration tests (real `Cx`, factory-built widget, real
TouchUpdate/KeyDown events) cover the first-touch drop into View and
key gating (arrows and Ctrl+B asserted inert), the Edit toggle
restoring keyboard editing, in-cell Backspace gating in View, and
Edit-mode tap placement with immediate continued editing. Physical IME
behavior (keyboard actually opening, candidate bar geometry) remains
device verification on Android/iOS.
## Touch cell-range selection (long-press + drag)
Merge is now reachable on touch devices, closing the last gap of the
CRDT-native merge/split milestone:
- Long-press inside a table cell falls through the text glyph hit test
into `table_hit_test` and starts a `TableCellSelection` with anchor =
focus = the pressed cell (`start_cell_range`), parking the in-cell
caret on it and clearing any text selection. Long-press on text still
selects the word's atoms exactly as before — the cell path only ever
fires when no glyph was hit.
- The gesture router idles in `Selecting` after a long-press (its `Move`
yields `None` there), so the widget owns drag tracking: while a cell
range is active, a continued drag moves the focus cell
(`extend_cell_range_to`) through the same tap hit geometry. Drags
landing outside the anchor's table keep the last focus; the caret
tracks the focus cell, and the existing highlight decorates the span
with no new draw code. Text word selections share the `Selecting`
router state but carry no cell range, so their behavior is untouched.
- Lifting the finger keeps the range; the workspace Merge button or
Ctrl/Cmd+M consumes it (`merge_selected_cells`, already public), and
Split works from a cell tap as before. The mode policy is unchanged:
the gesture runs in View and Edit alike, and merging stays a toolbar
action (as undo/redo already were).
Runtime integration tests (real `Cx`, factory-built widget, real
TouchUpdate + 24-frame NextFrame long-press clock) cover long-press
entry, drag extension with caret tracking and full-grid normalization,
merge consumption (start/end row+column asserted on the wire), drags
outside the table keeping focus with later extension intact, and the
text word-selection regression guard.
## Clipboard: copy, cut and paste across blocks and cells
`CrdtDocEditor` answers the platform clipboard queries (`Hit::TextCopy`
/`Hit::TextCut`, synthesized by the OS backends from menu and keyboard
shortcuts, exactly like makepad's own TextInput) and keeps paste on the
existing TextInput insert path:
- Copy yields the selection payload without editing: the text-block
selection joined with newlines (blocks keep their own line), or the
in-cell character span when the caret lives in a table. With nothing
selected the response stays empty, so the platform leaves the
clipboard alone.
- Cut fills the same payload and removes it through the shared
selection-deletion path: same-block spans via `replace_text_range`
with an empty replacement, in-cell spans via the whole-cell write.
Undo restores the deleted atoms through the engine's `RestoreText`
compensation (same-block spans) or tombstones the `ReplaceBlockRange`
op itself via `CancelBlockRange` (multi-block spans), so a cross-block
cut or selection delete re-materializes every drained block in a
single undo step, and redo re-cuts.
- Paste arrives as regular TextInput: cursor-block typing replaces an
active selection exactly like typed input; plain in-cell payloads
splice into the cell text. Payloads containing newlines split
block-per-line — see the next section, and since the
tabular-paste milestone an in-cell payload carrying tabs/newlines
distributes across the table instead of staying a single
whole-cell write.
Two production bugs this uncovered and fixed at the source: same-block
selection deletion never flipped its applied flag (the empty
replacement has no trailing atom), so Backspace over a selection
over-deleted by one char and left the anchors live; and the engine's
union-minus-union tombstone resolution made delete→undo→redo leave
targets permanently alive — every tombstone pair (text, blocks, rows,
columns, merges, cell styles) now resolves chronologically, with
`delete_text` finally pushing its symmetric `RestoreText` compensation
to make deletions undoable.
Runtime tests cover copy/cut payloads in blocks and cells with undo,
the no-selection no-payload case, newline-joining across blocks,
multi-block cut undoing back to every drained block in one step with
redo re-cutting, and the over-delete + undo/redo regressions; engine
tests cover the chronological tombstone cycles for text, blocks, rows,
merges, cell styles and block ranges (cancel/restore, text typed
beneath a cancelled range surviving, and unresolved-span refusal).
## Multi-line paste: block-per-line splitting
A TextInput payload containing `\n` (a clipboard paste of several
lines, or a programmatic multi-line insert) no longer lands as a single
run of text with literal line feed characters:
- The payload splits into one block per line like a desktop editor:
line 0 splices into the caret block at the caret, middle lines become
sibling blocks inheriting the caret block's kind, and the trailing
`SplitBlock` carries the caret block's suffix onto the last pasted
line, so pasting `l0\nl1\nl2` mid-word into `ab|XY` yields `abl0`,
`l1`, `l2XY`. The pasted caret parks after the last pasted atom (or
at the head of the empty tail a trailing newline leaves). CRLF
payloads have their `\r` stripped per line.
- The caret anchor handed to the engine may be TOMBSTONED — the caret
a selection delete leaves behind — and resolution matches the
single-line typing path exactly (`CrdtDocument::live_offset_of`
counts the live atoms preceding the tombstone), so pasting over a
freshly deleted selection lands where the selection began.
- The whole paste lands on the undo stack as ONE
`Compensation::Group`: the engine pops each sub-edit's individual
compensation into a group, so a paste of N lines retracts in a
single Ctrl+Z instead of walking N+1 entries. Pasting over an active
selection stays two steps (selection delete, then paste), matching
typed input. Plain in-cell pastes keep the existing whole-cell
write path, and a table cell never spawns blocks either way —
since the tabular-paste milestone below, a tab/newline payload
into a cell distributes across the table instead.
Fixing this surfaced a deeper engine gap at the source: the synthetic
block a `SplitBlock` opens (Return) used to be a text dead end — atoms
and styles addressed to it landed in the op log but evaporated from
the projection, and the child always spliced directly behind its
parent regardless of blocks authored under that parent first. Split
children are now first-class materialization targets (suffix atoms
re-linked head-to-tail keep their ids and inherited style runs,
child-addressed atoms splice in RGA-wise and take the block default)
and the child's splice skips the parent's real-chain descendants, so
multi-line paste and plain repeated splits both order correctly.
Runtime tests cover the split/suffix-carry/caret/undo/redo cycle,
paste-over-selection as two undo steps, and the in-cell newline
guard; engine tests cover grouped undo chronology, tombstone anchors,
empty middle lines, CRLF stripping, table refusal, peer convergence,
and the synthetic-child text/style/order regressions underneath.
## Mobile clipboard menu (long-press)
The touch clipboard surface is complete: a long-press selection now
floats the platform clipboard menu (`cx.show_clipboard_actions`, iOS
and Android backends), and its actions re-enter through the
synthesized hits the editor already answers — Copy/Cut arrive as
`Hit::TextCopy`/`Hit::TextCut`, Paste as a TextInput that also flows
through the multi-line block splitting from the previous milestone.
- The request fires from the frame-clock long-press arm right after
the selection lands — word atoms on text, or the armed cell range on
a cell — but only in Edit mode: View keeps the gesture for
merge/highlight exactly as before, and desktop sessions never reach
the touch-driven clock (their OS backends sink the op anyway).
- `has_selection` follows the copy-payload availability exactly like
the native menu: a selected word gets Copy/Cut/Paste, and — since
the cell-range clipboard milestone below — a cell range gets the
full action set too (it copies its tabular text), anchored on the
pressed cell's rect; a word selection anchors on the union of its
glyph rects.
- The request is mirrored on the widget as
`clipboard_menu: Option<ClipboardMenuRequest>`: makepad's platform
op queue is crate-private, so hosts rendering their own menu (and
tests asserting the request) read it there instead.
Runtime tests (real TouchUpdate + the 24-frame NextFrame long-press
clock) cover the Edit-mode word menu request with its rect and the
Copy payload flowing back out, the cell menu rect matching the
pressed cell with a menu Copy yielding its text and a menu Paste
splicing into the parked cell (the menu gesture parks the caret at
the cell end), and View mode making no request at all. Physical
device menu behavior (menu placement, keyboard-shift adjustment)
remains device verification on Android/iOS.
## Select all (Ctrl/Cmd+A)
Ctrl/Cmd+A selects the whole current editing context, mirroring
makepad's `text_input` select-all. With the caret parked in a table
cell the context is that cell's text: the in-cell character selection
spans its full length (any armed merge range collapses), the same
span Shift+Arrow reaches at the cell edges, so Copy, Cut, style
toggles and Backspace-over-span all apply to it unchanged. Otherwise
the anchor lands on the first layout glyph and the focus — with the
caret — on the last, so the multi-block Copy/Cut/delete and style
paths treat the result exactly like a maximal Shift+Arrow selection.
- Table blocks between the endpoints carry no glyphs; they ride the
range like any middle block — since the document-payload milestone
below, their grids join the clipboard payload as tab/newline lines,
and a cut drains them through `replace_block_range`, so one undo
step restores the whole document, grid included.
- A document without glyphs (empty, or tables only) has nothing to
select — the caret stays put, matching the atom-pair selection
model where a collapsed anchor reads as no selection.
- Touch sessions in Edit mode float the platform clipboard menu on
the fresh selection (the keyboard-select-all pattern from
`text_input`), mirrored through `clipboard_menu` exactly like the
long-press request; a desktop Ctrl+A never makes a menu request.
Runtime tests cover the document-wide span with the caret parking at
the last glyph, a select-all cut draining the document to one empty
block with a single Ctrl+Z restoring both blocks, the in-cell
whole-text span feeding Copy and Backspace-over-span (with undo), and
the touch Edit-mode menu request anchored on every glyph's rect.
## Cell-range clipboard payload
An armed table cell range now participates in the clipboard like any
other selection. Copy joins the normalized rectangle as tab/newline
text — rows top to bottom, cells left to right — the spreadsheet
convention, so a range round-trips through plain text editors and
other tables. Cut and Backspace/Delete clear every non-empty spanned
cell: the engine's new `set_table_cells` lands the writes as ONE
`Compensation::Group` (mirroring multi-line paste), so the span
un-clears in a single undo step; Backspace previously dropped the
range and edited only the caret cell.
- Cell values holding tabs, newlines, CRs, or quotes are quoted
RFC-4180-style on the way out and restore verbatim on a later
paste (the quoting milestone below); already-empty cells are
skipped so a clear lands neither redundant LWW ops nor dead group
members. A single-cell "range" write keeps the plain leaf
compensation, behaving exactly like `set_table_cell`.
- The long-press cell menu follows automatically: `has_selection`
reads the same payload, so a range now floats the full action set
anchored on the pressed cell instead of a paste-only menu.
- Shipping grouped cell writes surfaced a real engine asymmetry: an
undo AFTER a redo re-applied the redone cell text, because redo
pushed the write's own text as its undo compensation (the
compensation `inverse()` swaps the verb but keeps the text) while
undo's special-case captured live cell state only in one
direction. Both transition boundaries now capture the projected
text — undo's redo-side per group member too — so cell edits
round-trip through arbitrary undo/redo cycles, in groups and as
leaves. (The previous "cells stay out of groups" restriction is
gone.)
Runtime tests cover the tabular Copy payload through the TextCopy
hit, a range Cut clearing both fixture cells with one undo restoring
them (and the undo→redo→undo cycle re-restoring), Backspace clearing
the span without touching the parked caret, and the amended
long-press menu test asserting the full menu with its Copy payload.
Engine tests cover the leaf redo asymmetry regression, the grouped
multi-cell one-step undo with round-trip, and single/empty write
lists keeping leaf semantics.
## Tabular paste
Completing the cell-range clipboard: a TextInput carrying tabs or
newlines while the caret lives in a table now pastes the spreadsheet
way — one cell per tab stop, one row per line — starting at the
caret cell, or at the armed range's normalized top-left (consuming
the range like any paste-over-selection). The writes go through the
grouped `set_table_cells` from the previous milestone, so the whole
rectangle un-pastes in ONE undo step, and the caret parks at the
last cell the payload touched ready to keep typing.
- Payload rows or columns past the table edge clip (tables do not
auto-grow), CRLF payloads have their `\r` stripped per line just
like text-block pastes, empty fields clear their target cells, and
cells whose text would not change are skipped so a paste lands
neither redundant LWW ops nor dead group members.
- A payload without tabs or newlines keeps the whole-cell char-splice
path unchanged, and cells never spawn blocks with or without the
distribution — the pre-existing in-cell paste test was rewritten
from the old "newline stays embedded in the cell" semantics to
assert the distribution instead (that old behavior is where cell
strings holding `\n` came from; new pastes no longer create them).
- The mobile menu integrates for free: its Paste action arrives as a
TextInput, so a long-press-driven paste distributes from the
pressed cell across the rectangle.
Runtime tests cover the 2x2 rectangle distribution with caret
parking and the one-undo round trip (and redo re-pasting), edge
clipping without wrap-around, the backward-spanned range pasting
from its normalized top-left, CRLF stripping with empty-field
clears, and the menu-driven paste distributing from the pressed
cell. The engine integration test replays a grouped multi-cell
write's op log into a peer and asserts the projections converge —
the same guarantee every other cell edit already had. The round-trip
caveat from the copy milestone — cells holding raw tabs or newlines
re-distributing across the grid — is closed by the RFC-4180 quoting
milestone below: special values are quoted on copy and restored
verbatim on paste.
## Document-level payloads: table grids in block selections
The last hole in the clipboard surface is closed: a block-span
selection — Shift+Arrow across a table, select-all, any multi-block
drag — now carries table content in its payload. Table blocks carry
no glyphs, so anchor and focus still land on text, but every table
between the endpoints contributes its whole grid at its block
position, as tab/newline lines through the same `table_grid_tsv`
builder the cell-range payload uses: one builder, one convention,
no drift between "copy a range" and "copy across a table".
- The export carries stored cell text verbatim: a merge's covered
cells keep their (hidden) values, and special values are quoted
RFC-4180-style like any other tabular payload (the quoting
milestone below). Empty tables (no rows or columns) contribute
nothing, exactly the blank line they left before.
- Cutting such a span was already correct at the structural level —
`replace_block_range` drains the table block and
`CancelBlockRange` re-materializes it on undo — so the milestone is
payload-only: the payload now matches what actually disappears,
and the round trip (copy → paste back through tabular paste)
rebuilds the grid in any table.
- The select-all bullet in the earlier section claimed tables were
skipped; that claim is retired with this milestone.
Runtime tests cover select-all over [paragraph, 2x2 table,
paragraph] yielding "lead\na\tbc\nd\te\ntail" through both
`copyable_selection_text` and the TextCopy hit, a full-span cut
draining the document with the grid in the payload and one undo
restoring blocks AND every cell value, and a partial mid-paragraph
span splicing the grid between its text fragments in order.
## Tabular clipboard quoting: RFC-4180-style round-trip
The last documented caveat of the tabular clipboard milestones is
closed: cells holding tabs, newlines, CRs, or quotes no longer
re-distribute across the grid on a copy/paste cycle. The writer
side lives in the single `table_grid_tsv` builder — so the cell-range
payload, the block-span document payload, and any future consumer
inherit it at once: a field carrying one of the special characters
is wrapped in double quotes with every inner quote doubled
(`quote_tabular_field`); plain and empty fields stay raw, preserving
byte-for-byte compatibility with payloads from spreadsheets and
plain text editors.
The reader side replaces the naive `split('\n')` / `split('\t')`
walk in `paste_table_payload` with `split_tabular_payload`, a small
RFC-4180-style tokenizer:
- A quote opens quoted mode only at the very start of a field; a
quote mid-field is literal text (lenient, like Excel).
- Inside quotes, a doubled `"` reads as one literal quote, and
tabs/newlines/CRs are literal field text — so a cell value like
`"line one\nline two"` lands back in ONE cell.
- Outside quotes, rows end on `\n` with a trailing CR stripped
(CRLF tolerance kept from the raw milestone); an unterminated
quote reads to the end of the payload as best-effort text, and a
single trailing newline adds no phantom row while a deliberate
trailing empty row survives.
- Empty quoted fields round-trip as empty cells, and the
already-empty/no-op skip and caret-parking semantics of the raw
paste milestone are unchanged. Caret offset in a multi-line value
counts its full text, newline included.
Unit tests pin the writer (quoting rules plus a quote/split
round-trip over every special case) and the tokenizer (quoted
tabs/newlines/CRs, doubled quotes, CRLF rows, mid-field quotes,
unterminated quotes, empty quoted fields, phantom-row rules).
Runtime tests pin the integration both ways: an armed range with
tab/newline/quote values copies as a quoted payload (plain cells
stay raw), a pasted quoted payload keeps embedded tabs and newlines
inside their cells with caret parking and a one-undo round trip,
and an end-to-end copy → cut → paste cycle restores every special
value verbatim. A doc-engine materialize test pins the data-layer
guarantee the feature leans on: special-character cell text
materializes verbatim on peers and restores verbatim through
undo/redo.
Remaining caveats, unchanged or deferred by design:
- Rendering of multi-line cell values no longer collapses the
newline: the next milestone grew rows to fit and draws each
display line.
- Merge structure is still not carried by clipboard payloads —
stored cell text is, and covered cells keep their (hidden) values.
- Tables still do not auto-grow on an oversized paste; out-of-bounds
payload rows and columns clip.
## In-cell newline rendering: rows grow to fit multi-line values
Multi-line cell values — whether legacy strings holding `\n` or fresh
ones the RFC-4180 quoting round-trip now produces — finally render
every display line instead of collapsing inline. The change threads
one shared line model through layout, renderer, caret, highlight,
hit test, and the keyboard surface, so all of them always agree
about where a character is:
- `layout_projected_table` grows a row by one
`TABLE_CELL_TEXT_LINE_HEIGHT` (18px) per extra display line of its
tallest visible cell over the fixed 28px baseline (`TABLE_CELL_HEIGHT`);
the table rect and every block below shift with it. Column widths
stay fixed, and single-line tables lay out byte-identical to
before (the control assertions pin this).
- Geometry composes with merges: a covered cell's hidden text never
inflates its row, and a vertical merge anchor sums the grown
heights of the rows it spans.
- The renderer draws styled runs segment by segment — a `\n` inside
a run resets x to the inset and advances one line — with the whole
text block vertically centered, so single-line cells draw exactly
where they always did.
- `table_cell_caret`, the selection bands (`cell_text_span_rect`,
now `cell_text_span_rects` with one rect per covered display
line), and the pointer hit test (`cell_char_offset_at`, now
point-based: y picks the band, x midpoint-splits within that
line) all resolve offsets through one `cell_text_line_col` /
`cell_text_offset_at` pair; the round-trip property between them
is unit-tested for every boundary, including empty lines and the
newline's own offset (line-end of the previous display line).
- ArrowUp/ArrowDown, previously dead in cell mode, step between
display lines keeping the visual column (clamped per line), with
Shift extending the in-cell character selection vertically; they
stay inert at the first/last line and on single-line cells — no
implicit row exit, and an armed cell range is never half-moved by
a vertical key (range arithmetic stays on the horizontal walk).
Defect found and fixed in this milestone: an in-cell character span
covering a newline copied as a RAW slice (block selections and cell
ranges quoted since the previous milestone; the in-cell path predates
both), so copy → paste re-distributed the slice across the table. The
in-cell branch of `copyable_selection_text` now runs the same
`quote_tabular_field`, and the Shift+ArrowDown runtime test pins the
quoted payload end to end.
Tests cover the line math boundary-by-boundary (including empty and
trailing lines), row growth with block flow and merge composition,
multi-line caret rects, per-line selection bands, point hit testing
with clamps, vertical-arrow stepping/inertness/collapse behavior, a
real tap parking on the tapped display line, and the quoted span
copy through both `copyable_selection_text` and the TextCopy hit.
## Legacy perf boxes: retirement decision and the CRDT-native layout cache
Four roadmap boxes stayed open long after everything around them
landed ("Incremental page/block reflow execution", "Reuse cached
fragment payloads during draw traversal", "Wire commands to touch
only affected block revisions", and the renderer draw-pass
integration test). This milestone closes each with an explicit
decision instead of leaving the list ambiguous.
Context: `DocWorkspace`/`DocEditor` is the fallback path;
`CrdtDocWorkspace`/`CrdtDocEditor` is the active editor
(`workspace/mod.rs` documents the split). The three legacy perf
boxes were written for the legacy layout/draw pipeline, whose
foundations (block revisions, fragment caches, invalidation
tracking) are checked above but whose final wiring would buy
performance only on a path nothing ships through. Completing them
there would be speculative double-maintenance, so each is retired
against the legacy fallback and, where the underlying need is real,
answered on the active CRDT path:
- **Wire commands → block revisions:** retired. The CRDT-native
editor does not need per-command revision bumps: change detection
keys on the engine's op version-vector sum, which every mutating
op — edit, undo, redo, peer import — bumps exactly once. The
legacy revisions foundation stays (the fallback keeps its checked
cache-population semantics); the final per-command wiring is
retired rather than implemented.
- **Incremental page/block reflow execution:** retired for the
legacy path; the CRDT answer is `CrdtDocEditor::layout_tree`, a
document-keyed cache of the whole `ProjectionLayoutTree`. All
~two dozen consumers (event handlers, drag tracking, the draw
walk) recompute once per document change instead of once per
consumer per keypress — an unchanged document serves an `Rc`
clone of the same tree. Granularity is one change key rather than
per-block re-layout: the tree build is a single O(blocks +
glyphs) pass, so per-block refinement buys nothing until a
profile says otherwise.
- **Reuse cached fragment payloads during draw traversal:** retired
for the legacy renderer; the CRDT draw walk reuses the same
cached layout tree as every other consumer — the glyph/rect
payloads ARE the cache, shared rather than duplicated in a
second, draw-only structure.
- **Renderer draw-pass integration tests:** resolved by scoping.
Painting and clipping against a live GPU surface cannot run in
the sandbox CI (the roadmap note already said so); the parts that
CAN regress — layout geometry, table/caret/selection rects, hit
tests, cell ranges, draw-free event flows — are covered by the
real-`Cx` runtime harness with `Area::Rect` stubs. Visual
verification lands with the device-verification batch on
Android/iOS hardware (the same batch as the ScrollYView parent
handoff next to it in the list).
`set_engine` drops the cache slot outright, so a swapped-in engine
can never inherit another document's tree under a colliding key.
Runtime tests pin the cache both ways: pointer-identity reuse on an
unchanged document, invalidation plus fresh geometry after a cell
edit AND after undo, and no stale-tree inheritance across an engine
replacement.
Open legacy roadmap item, unchanged: ScrollYView parent handoff
verification on Android/iOS — belongs to the device-verification
batch.
## Device verification runbook (last sandbox-actionable artifact)
With every other roadmap item closed, one box legitimately cannot
execute in the sandbox: ScrollYView parent handoff verification on
Android/iOS — and with it the whole class of platform-owned behaviors
deferred across the touch milestones (IME opening and its
frame-driven reassert, native clipboard-menu placement against the
soft keyboard, touch drag-vs-scroll arbitration on real event
streams, and the GPU-bound painting/clipping sweep). This milestone
writes the checklist that turns a hardware session into pure
execution: `DEVICE_VERIFICATION.md` in this folder.
It is anchored to code, not vibes: every section names the mechanism
under test (the router's 10 px / 24-frame arbitration,
`cx.show_text_ime` and its NextFrame reassert,
`cx.show_clipboard_actions` with the `keyboard_shift` passthrough,
the `start/extend_cell_range` touch-only spanning path, the
RFC-4180 quoted payloads, the grown-row multi-line layout) and each
row has an expected outcome plus explicit fail criteria — including
which failures must be filed instead of waved through. The legacy
box is covered on both editors (`crdt_body` AND the fallback
`body_scroll`); the sign-off table gates checking the box on both
columns passing.
Everything the harness CAN prove stays proven there: the runtime
suite covers the logic behind each row, so the runbook deliberately
re-verifies only the platform-owned residuals. No code changes in
this milestone beyond documentation; the roadmap box gains a pointer
to the runbook for the hardware session.
## Boot-time document init and app-data persistence (Android empty-doc fix)
The first hardware run of the roadmap surface exposed a compound
defect no sandbox gate could see: the Android APK (`pageflipnav`)
booted the doc workspace to a BLANK page. Two independent causes:
1. **The CRDT editor had no boot init.** The legacy `DocEditor` seeds
the showcase document behind an `initialized` flag on first draw,
but `CrdtDocEditor` — the ACTIVE editor since the navigation switch
— starts from `DocumentController::default()` (an empty projection)
and only ever gained content through an interactive action a fresh
install has not performed yet.
2. **Persistence pointed at the build machine's source tree.**
`persistence.rs` resolved its save file under
`env!("CARGO_MANIFEST_DIR")`, an absolute path baked in at compile
time. On device that path does not exist, so Open silently read
nothing and Save silently wrote nowhere (`.ok()` swallowed the
failure); on a developer machine the app polluted its own checkout.
The fix mirrors the legacy boot contract exactly once, inside the
editor: the first event handled by a factory-fresh editor runs
`init_document`, which loads the on-disk save when it decodes as
`#MP_CRDT_V1` wire (`initial_document_source` is the gate — classic-
format saves belong to the legacy workspace's first-edit migration and
must not be shadowed) and otherwise calls `seed_demo_doc`, a CRDT
mirror of the legacy `demo_doc_blocks()` showcase: styled headings,
accent runs, a divider, an image node, the 4x3 table with a bold
header, and the closing hint. `set_engine` flips the same flag, so a
host that installs its own document before the first event is never
overwritten by the seed (this also keeps every runtime test harness
deterministic).
Persistence migrates to the crate-wide convention
(`crate::dir::app_data_dir()`, the root the CAD store already uses):
writes go ONLY to `nigig_build_store/generated/current.doc.json`
there, while reads keep a one-way fallback to the legacy source-tree
file so an unreplicated developer save is honored once. Both the boot
and the migration emit `[DOC_TRACE]` lines (mirroring the legacy
boot's instrumentation) so a device `logcat` session confirms which
branch fired.
Tests pin the whole contract: the boot-source gate (valid CRDT wire
boots verbatim; classic JSON and `None` both route to the demo seed),
a runtime boot test (first event on a factory-fresh editor flips the
flag and leaves a non-empty projection, source-agnostic by design), a
no-overwrite guard for host-installed engines, a full structural
assertion of the seeded showcase (heading runs, node kinds in order,
the 4x3/12-cell table with bold header), and four persistence tests
over temp dirs covering the round trip, store-beats-manifest
precedence, the manifest fallback, and empty-file rejection.
`DEVICE_VERIFICATION.md` section 9 gained the matching hardware rows.
## Engine source coverage (gated)
The doc engine now has what the CAD engine got first: a measured,
gated coverage number instead of an assertion.
`tools/test-doc-engine-coverage.sh` runs the crate's unit tests plus
`tests/materialize.rs` under `-C instrument-coverage` in an isolated,
self-deleting environment and enforces a total floor (96% lines)
against a measured baseline of 99.00% (97.54% regions), with per-file
floors so losing one module's tests cannot hide inside the total. The
harness needed no shim layer: doc-engine is UI-free (serde +
serde_json), which is also why the whole run takes seconds. The run
report named real gaps, closed in the same tranche: offset-addressed
text insert/delete, block alignment materialization, batched cell
group undo/redo, the `#MP_CRDT_V1` wire round trip, and
toggle/batch-reject guards. Two assertions came back inverted and were
pinned as DOCUMENTED behavior instead: writes and style ops
addressed to blocks or cells whose anchors have not arrived are
accepted into the op log (CRDT store tolerance — they must merge when
the anchor lands) while conjuring no blocks, rows or columns into the
rendered document. The baseline, the
exclusions, and what the number does not mean live in
`crates/apps/doc/doc-engine/COVERAGE.md`; the gate runs in the
doc-engine workflow.
## Clipboard menu re-float on selection-handle drag
A mobile selection flow had one stale anchor: the native menu floated
at long-press word-select (or select-all), but dragging either handle
afterwards re-anchored nothing — the platform toolbar stayed where the
untouched word was, or had already been dismissed by the adjustment.
The router's `end` only distinguishes PendingLongPress from everything
else, so the Stop arm now samples the gesture state first: when the
ended gesture was a handle adjustment and the session is in Edit mode,
the menu re-floats on lift-off via the same
`cx.show_clipboard_actions` request shape as the long-press arm, with
`rect` = the ADJUSTED selection's handle union (through the existing
`clipboard_menu_rect`). Mid-drag stays quiet — matching TextInput
cadence, which DEVICE_VERIFICATION 3.3 documents — and View mode keeps
the drag as pure highlight/merge surface (new regression row 3.6).
Runtime tests drive the full sequence (long-press "hello", grab the
end handle, drag onto 'w' in "world"): no request mid-drag, a fresh
request on lift-off whose rect is wider than the stale one, focus
landed on the dragged-to atom; the View-mode twin asserts the span
adjusts but `clipboard_menu` stays empty.
## Doc-workspace coverage gate (pure layer at 96.76% lines)
The doc module had exactly the problem the CAD and doc-engine gates
were built for: a host-only-testable core that had never been measured
because `cargo test -p nigig-build` links wayland/X11/GL/alsa/polkit.
The test suite is now split in two — `tests_pure.rs` holds every test
that needs no `Cx` (model, layout, editing, collaboration,
advanced JSON, projection layout/session, CRDT bridge, persistence
seams, mobile gestures), and `tests.rs` keeps the widget-runtime and
boot tests. `tools/test-doc-workspace-coverage.sh` then copies the
pure sources plus `tests_pure.rs` into a temporary host-only crate
with a makepad-math shim (the same shape as the CAD gate), runs them
under `-C instrument-coverage`, and enforces a total floor plus a
per-file floor for every instrumented file. Baseline was **28.55%**
lines; the gate now holds **96.76%** with floors a few points under
per file (persistence keeps a documented lower floor — see
`COVERAGE.md` for the honest exclusion list and exact numbers). A new
`doc-workspace-coverage` CI job runs the script on every push that
touches the crate, and the script self-reports any pure file that
appears without a floor so the classification cannot silently rot.
Growing the suite sat on the roadmap long enough that the exercise
also surfaced real behavior worth pinning, and two outright defects
that are now fixed: `Command::ReplaceBlockRange` used to validate its
explicit `block_ids` length after draining blocks out of the document
(a malformed remote command destroyed content before failing), and
`RgaText::visit_children` was a dead String-collecting duplicate of
`visit_atoms` (removed). The controller tests also nail two semantics
that were previously only folklore: remote typing between two
`DocumentController`s only converges when each peer owns a distinct
`document.crdt.local_actor` (the sync test assigns `alice`/`bob`),
and a mid-range `replace_range_crdt` renders its replacement after
the tombstoned subtree it replaced, because RGA siblings walk in id
order (`"hello" -> "hloY"` is pinned with the reasoning inline). The
baseline, the per-file floors, and what intentionally stays outside
the measurement (the whole widget layer, the persistence write-path
wrappers, the defensive traversal guards) live in `COVERAGE.md`; the
harness writes nothing outside a shell-trap-cleaned mktemp dir.