|
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
|
||
|---|---|---|
| .. | ||
| src | ||
| Cargo.toml | ||
| COVERAGE.md | ||
| DEVICE_VERIFICATION.md | ||
| README.md | ||
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
DocEditorand toolbarDocWorkspace - 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/revisionDocumentSession: per-view cursor and selection stateSnapshotHistory: 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 Commands. 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 DocumentOperations 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_imefromdraw_walkafter the focused draw area exists, which supports mobile backends that drop aFingerDown-time IME request. - Drag selection is explicitly captured by
pointer_selectingand 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
DocumentOperations 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 TextAtoms,
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 TextAtoms, 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
- Text insert/delete and selection replacement
- Paragraph split and merge
- Style ranges and alignment
- Table cell/row commands
- Block and image commands
- Typing coalescing
- Internal copy/cut/paste keyboard commands
- Native system clipboard copy/cut integration
- Native system clipboard paste via platform TextInput fallback
- 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") - Incremental layout invalidation tracking
- Incremental block fragment cache foundation
- Block fragment cache population during layout traversal
- Block fragment cache population during layout traversal
- Cached text measurement reuse
- 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" - Renderer extraction: divider block
- Renderer extraction: image placeholder block
- Renderer extraction: table cell primitive
- Renderer extraction: advanced block placeholders
- Mobile gesture arbitration state machine + tests
- 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) - AdaptiveView mobile Edit/Done toolbar control
- CRDT atom/block identities and local text edits
- CRDT remote buffering, presence, and tombstone frontier foundation
- Tombstone deletion timestamps and CRDT atom persistence
- CRDT table stable-ID model foundation
- CRDT advanced-block stable-ID model foundation
- Peer synchronization transport boundary + MemoryTransport
- Visual remote selections/cursors
CRDT tombstone timestamps
- 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
- Unit tests: reversible text commands
- Unit tests: controller undo/redo
- Unit tests: deterministic RGA tombstones
- Unit tests: table row commands
- Unit tests: block ID insert/remove invariants
- Integration tests: causal remote-operation buffering
- Integration tests: reversible table cell merge
- Integration tests: memory transport operation loop
- Unit tests: rectangular table selection
- Unit tests: advanced block layout
- Unit tests: typing coalescing
- Unit tests: tombstone compaction frontier
- Unit tests: layout invalidation tracking
- Unit tests: table column command inverse
- Unit tests: table merge/split inverse
- Unit tests: CRDT selected-range replacement inverse
- Unit tests: mobile selection-handle gesture routing
- Unit tests: advanced JSON canvas round trip
- Unit tests: advanced JSON recursive table round trip
- Unit tests: advanced JSON inline link/widget round trip
- Unit tests: block layout cache invalidation
- Unit tests: cached text measurement reuse
- Unit tests: CRDT-native projection table layout, merges and hit testing
- Unit tests: CRDT-native advanced node layout and unified order
- Unit tests: CRDT-native word atom ranges and selection-handle geometry
- Integration tests: widget input, selection, and mobile gestures
(real-
Cxruntime harness plus the draw-freeArea::Rectstub) - Integration tests: renderer draw pass — resolved as documented:
layout/hit/draw-order LOGIC is covered by the draw-free runtime
harness (real-
CxplusArea::Rectstubs); 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
- Versioned JSON DTOs for code, media, canvas, diagrams, and embedded widgets
- Loss-prevention errors for unsupported advanced node kinds
- JSON deserialization into runtime advanced nodes
- Persist paragraph/heading and core inline advanced nodes
- Persist advanced inline image/link/widget nodes
- Persist list/quote/columns/container recursive advanced nodes
- Persist advanced recursive table model
- Persist advanced image resources and captions
Layout extraction
-
Pure
layout_paragraphblock-layout function -
Paragraph layout unit test
-
Paragraph alignment layout test
-
Renderer support for ParagraphFragment
-
Wire DocEditor paragraph rendering to LayoutEngine fragments
-
Pure
layout_tableblock-layout function -
Table layout unit test
-
Wire DocEditor base table geometry to LayoutEngine fragments
-
Pure
layout_imageblock-layout function -
Image layout unit test
-
Wire DocEditor image geometry to LayoutEngine fragments
-
Pure
layout_dividerblock-layout function -
Divider layout unit test
-
Wire DocEditor divider geometry to LayoutEngine fragments
-
Pure
layout_pagespage-stack function -
Page layout unit test
-
Wire DocEditor page stack to LayoutEngine page fragments
-
Populate LayoutTree line/run records from paragraph fragments
-
Encapsulate paragraph fragment → LayoutTree transfer
-
Populate LayoutTree table-cell records from table fragments
-
Populate LayoutTree image records from image layout fragments
-
Populate LayoutTree divider records from divider layout fragments
-
LayoutTree caret geometry API
-
Unit tests: LayoutTree caret geometry
-
Unit tests: table-cell caret geometry
-
LayoutTree selection geometry API
-
Unit tests: LayoutTree selection geometry
-
LayoutTree nearest-hit API
-
Unit tests: LayoutTree nearest-hit lookup
-
Wire DocEditor fallback hit testing to LayoutTree nearest-hit API
-
Table render-cell fragment builder
-
Table render-cell merge geometry test
-
DocumentRenderer table-fragment draw API
-
Wire DocEditor table drawing to TableRenderCell fragments
-
Renderer extraction: page background/shadow primitive
-
Renderer extraction: remote presence primitives
Renderer extraction completion
-
Page background/shadow
-
Paragraph fragments
-
Table fragments and merged cells
-
Image placeholders
-
Dividers
-
Advanced block placeholders
-
Selection/caret/remote presence primitives
-
Remove remaining legacy caret calculations from DocEditor
-
LayoutTree caret support for empty paragraph spans and table cells
-
Block cache stores typed layout fragment payloads
-
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"
-
Per-block document revision foundation
-
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"
-
Replace DocEditor blanket layout invalidation with cursor-block invalidation
-
Command-range-aware invalidation handoff via DocumentController
-
ParagraphFragment matching-revision cache reuse
-
TableFragment matching-revision cache reuse
-
Image/divider matching-revision cache reuse
-
Unit tests: matching-revision block cache payload reuse
-
Cache geometry/origin validation
-
Unit tests: cache geometry validation
-
Cache height-change detection and following-fragment invalidation
-
Unit tests: height-change fragment invalidation
-
Page layout cache foundation
-
Unit tests: page cache invalidation
-
Populate page cache from page stack traversal
-
Reuse cached page fragments during page stack traversal
Incremental reflow checkpoint
-
Per-block revisions
-
Command-aware block invalidation
-
Typed fragment cache
-
Paragraph/table/image/divider fragment reuse
-
Height-change following-fragment invalidation
-
Page cache population/reuse
-
Page/block incremental reflow execution foundation
-
Long-document paragraph layout benchmark harness
-
Collaboration acknowledgement message protocol
-
Unit tests: collaboration acknowledgement transport
-
Synchronization-triggered acknowledged tombstone compaction
-
Unit tests: acknowledgement safe frontier
CRDT engine bridge
-
Temporary CRDT projection bridge source
-
Add
doc-engineCargo dependency to nigig-build -
Wire CrdtProjectionBridge into DocEditor controller
-
DocEditor CRDT projection bridge installation API
-
DocEditor CRDT projection refresh API
-
DocEditor CRDT operation dispatch API
-
Route unselected keyboard/IME text insertion through CRDT engine
-
Route unselected Backspace through CRDT engine
-
Route unselected Delete through CRDT engine
-
Migrate selected replacement, formatting, table, and toolbar actions to CRDT operations
-
Bridge projected CRDT table merge metadata into legacy renderer
-
Route table merge/split shortcuts to CRDT operations
-
Bridge CRDT projected advanced nodes into legacy advanced layout
-
Remove legacy end-of-document advanced-node rendering pass
-
Render inline AdvancedNodeRef through AdvancedLayout/DocumentRenderer
-
Preserve CRDT table merge block indexes under unified projection order
-
Route image/divider insertion toolbar actions through CRDT InsertNode
-
Route inline advanced node deletion through CRDT DeleteNode
-
Route table insertion toolbar action through CRDT InsertBlock table
-
Save/Open CRDT operation log when CRDT engine is active
-
Legacy delimiter save fallback
-
Automatic legacy-to-CRDT migration on first text edit
-
Preserve bold/italic/underline StyleSpan formatting during CRDT migration
-
Preserve legacy font size/color during CRDT migration
-
Route alignment toolbar actions through CRDT SetBlockAlignment
-
Route Undo/Redo through CRDT controller when active
-
Unit tests: CRDT projection bridge text/style materialization
-
Unit tests: CRDT projection bridge table materialization
-
Unit tests: CRDT projection bridge inline advanced node references
-
Unit tests: CRDT bridge unified block/node order
CRDT-native widget rewrite
-
CrdtDocEditor skeleton
-
ProjectionSession skeleton
-
ProjectionLayoutTree skeleton
-
ProjectionRenderer skeleton
-
CRDT-native projected styled text rendering
-
CRDT-native basic text input interaction
-
CRDT-native basic selection highlight
-
CRDT-native basic pointer drag selection
-
CRDT-native mobile long-press/handle selection
-
CRDT-native table rendering
-
CRDT-native advanced node rendering
-
CRDT-native keyboard editing
-
CRDT-native in-cell table editing
-
Switch workspace DSL to CrdtDocEditor
-
CrdtDocEditor engine installation API
-
CrdtDocEditor default CRDT paragraph initialization
-
CrdtDocEditor AtomId cursor anchor updates on input
-
Projection atom glyph layout and pointer hit testing
-
CRDT-native caret rendering
CRDT-native vertical slice
-
CRDT paragraph creation
-
Atom text insertion
-
Projection layout glyph hit
-
Insert after hit atom
-
CRDT undo
-
CRDT JSON save/load
-
CrdtDocEditor widget runtime integration test
-
Separate CrdtDocWorkspace runtime-test view
-
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
ProjectedTablecell 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
coveredwith 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_testmaps 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_projectionwalks the unifiedDocumentProjection::order, so advanced nodes interleave with paragraphs, headings and tables at their exact anchor positions instead of rendering as one trailing strip.projected_node_metricsmirrors the legacyAdvancedLayoutheights, labels and interactivity flags per kind (image 220, canvas 240, divider 18, unknown kinds map toWidget: <kind>exactly like the bridge'sEmbeddedWidgetfallback), keeping both views visually identical.ProjectionRenderer::draw_node_projectionreuses the legacydraw_advanced_blockscolors (interactive/non-interactive fill tint, top/bottom borders, kind label); dividers collapse to a centered 1px line. Two new live fields,draw_node_fillanddraw_node_border, hold the script defaults.ProjectionLayoutTree::node_hit_testresolves 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:
TouchUpdateStart 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
SelectWordaction maps the press point through the glyph hit test intoword_atom_range, which mirrors the legacyword_boundswhitespace-pivot semantics and returns the word's first/last atoms as the selection. selection_handlesnormalizes 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 newdraw_selection_handlelive 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_downroutes Command/Ctrl +Z(undo) andShift+Z(redo) throughCrdtHistory, then sanitizes the cursor back onto a live projection atom. Command/Ctrl +B/I/Utoggle bold, italic and underline over the selection's atom range viatoggle_selection_style. Arrow keys move (or with Shift extend) the selection edge one glyph at a time across block boundaries using the projection-widestep_glyphstream.Backspace/Deletefirst delete a non-empty selection as one atom batch; at block boundaries they merge whole blocks.Returnopens 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 }andOperation::MergeBlocks { block }with symmetricCompensation::{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_runscoalesces adjacent runs with equal bold/italic/underline/font_size/color so the merged text stays minimal. TextInputnow 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_V1wire (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 routeLeft/Center/Right— the sameDocAligndebug strings the legacy toolbar puts on the wire — throughset_block_alignment, and+Table/+Img/+Divcall the newinsert_table/insert_image/insert_dividerhelpers 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_tabskeeps one "Docs" tab (docs_contentcontainingCrdtDocWorkspace); thecrdt_docs_tabdefinition, itscrdt_docs_contentview, the sidebar's "Documents CRDT Test" button and itsselect_tabhandler 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(whosem_doc_contentisCrdtDocWorkspace). That mapping is now the pure functionworkspace_page_idinpages/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_newfactory the production widget registry calls (a bareScriptVmwith unit host/std suffices, per makepad's own script test pattern), and the engine is installed through the publicset_engineAPI. - Real
Event::KeyDownvalues with realKeyEvent/KeyModifierspayloads enter throughWidget::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 aTableCellCursor(table/row/column ids + char offset) on the char under the pointer (cell_char_offset_atmidpoint-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
SetTableCellreplacements — the legacyReplaceTableCellsemantics — 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_blockskips 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_selectionis aTableCellSelectionof stable anchor/focus row/column ids. Shift+Arrow at a cell boundary (or on an active range) starts/steps the focus cell throughshift_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_rangenormalizes 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_rectsyields 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'sMergeTableCellsop; the symmetricSplitTableCellscompensation 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 viamerge_at_cell— and splits it through the engine; undo re-merges throughRestoreTableMerge. - 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(Editdefault,View) and amobile_mode_initializedlatch: 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
AdaptiveViewcontrol (empty variant on desktop). Tapping it flipstoggle_interaction_mode: entering Edit takes key focus and mirrors the state on the button label ("Edit"/"Done"); entering View callshide_text_imeand 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_imeevery 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_modeand theinteraction_mode/mobile_mode_initializedfields 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_testand starts aTableCellSelectionwith 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
Selectingafter a long-press (itsMoveyieldsNonethere), 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 theSelectingrouter 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_rangewith an empty replacement, in-cell spans via the whole-cell write. Undo restores the deleted atoms through the engine'sRestoreTextcompensation (same-block spans) or tombstones theReplaceBlockRangeop itself viaCancelBlockRange(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
SplitBlockcarries the caret block's suffix onto the last pasted line, so pastingl0\nl1\nl2mid-word intoab|XYyieldsabl0,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\rstripped 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_ofcounts 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_selectionfollows 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 throughclipboard_menuexactly 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_selectionreads 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
\rstripped 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
\ncame 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_rangedrains the table block andCancelBlockRangere-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
\nwith 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_tablegrows a row by oneTABLE_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
\ninside 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, nowcell_text_span_rectswith 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 onecell_text_line_col/cell_text_offset_atpair; 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 wholeProjectionLayoutTree. 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 anRcclone 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-
Cxruntime harness withArea::Rectstubs. 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:
- The CRDT editor had no boot init. The legacy
DocEditorseeds the showcase document behind aninitializedflag on first draw, butCrdtDocEditor— the ACTIVE editor since the navigation switch — starts fromDocumentController::default()(an empty projection) and only ever gained content through an interactive action a fresh install has not performed yet. - Persistence pointed at the build machine's source tree.
persistence.rsresolved its save file underenv!("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
DocumentControllers 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.