Tabular paste superseded the clipboard section's "in-cell pastes
always stay a single whole-cell write, newlines included", and the
doc-engine README still framed the controller migration as "next"
even though the workspace navigation already runs on CrdtDocEditor
(the classic editor stays registered as a fallback). Found during
the phase audit.
An armed table cell range now copies as tab/newline text (rows top
to bottom, cells left to right, the spreadsheet convention) through
the same copyable_selection_text the TextCopy/TextCut hits answer
with, so the long-press cell menu floats the full action set instead
of a paste-only one. Cut and Backspace/Delete clear every non-empty
spanned cell via the engine's new set_table_cells, which pops each
sub-write's compensation into one Compensation::Group — the span
un-clears in a single undo step where Backspace previously dropped
the range and edited only the caret cell. Single writes keep the
leaf compensation and empty write lists record nothing.
Grouping cell writes surfaced a real undo asymmetry: an undo after a
redo re-applied the redone cell text, because redo pushed the write's
own text as its undo compensation (inverse() only swaps the verb,
keeping the stale text) while undo's leaf special-case captured live
cell state only in one direction. undo()/redo() now resolve
cell-text inverses per compensation member against the projected
text at both transition boundaries (redo_compensation/
undo_compensation), so leaves and groups round-trip through
arbitrary cycles and the "cells stay out of groups" restriction is
gone.
A pasted TextInput payload containing newlines used to land as a
single run of text with literal line feed characters. It now splits
block-per-line like a desktop editor: line 0 splices into the caret
block, 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 (ab|XY pasted with "l0\nl1\nl2" yields
abl0 / l1 / l2XY). The caret parks after the last pasted atom.
The whole paste folds into one Compensation::Group (undo applies group
members in reverse, redo applies the inverse group in authored order),
so N lines retract in a single Ctrl+Z. The caret anchor may be
tombstoned (the caret a selection delete leaves behind): the new
CrdtDocument::live_offset_of resolves it to the same live-text offset
RGA splicing uses, so pasting over a deleted selection lands exactly
where a single-line paste would. CRLF payloads strip their \r.
Surfaced and fixed at the source while building this: the synthetic
block a SplitBlock opens was a text dead end -- atoms and style ops
addressed to a split op id landed in the op log but evaporated from
the projection, and the child always spliced directly behind its
parent. Split children are now first-class materialization targets:
suffix atoms re-link head-to-tail (keeping ids and inherited style
runs), child-addressed atoms splice in RGA-wise, and the child's
splice skips the parent's real-chain descendants, keeping the middle
paste lines ahead of it.
Runtime tests cover the split/suffix/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, table refusal, peer convergence, and the
synthetic-child text/style/order regressions underneath.
ReplaceBlockRange pushed no undo compensation, so a multi-block cut or
selection delete could never be undone: the range op rewrites only the
projection (drained blocks and every atom beneath the span stay in the
op log untouched), and no op existed to make it stop applying.
Add the CancelBlockRange/RestoreBlockRange op+compensation pair,
resolved chronologically per target like every other tombstone pair
(merge splits, cell styles, blocks, text, rows, columns). Undo of a
cross-block delete tombstones the range op itself, which is lossless:
splices, middle blocks and their texts all re-materialize exactly from
the untouched log, in a single undo step. Redo clears the tombstone.
The controller also refuses spans whose endpoints do not resolve
against the projection in order, so a no-op range never strands an
entry on the undo stack.
Coverage: engine tests for the one-step undo/redo cycle (with a double
undo proving the chronological tombstone), text typed beneath a
cancelled range surviving, and unresolved-span refusal growing no undo
stack. Widget runtime test cuts across three blocks, asserts the joined
payload, then Ctrl+Z restores all three blocks and Ctrl+Shift+Z re-cuts.
The CRDT editor had no clipboard surface at all. It now answers the
platform clipboard queries exactly like makepad's TextInput:
- Hit::TextCopy / Hit::TextCut fill the response cell with the selection
payload: the text-block selection joined with newlines, or the in-cell
character span when the caret lives in a table. Nothing selected
leaves the response empty, so the platform keeps the clipboard alone.
- Cut removes the payload through the shared selection-deletion path and
paste flows through the existing TextInput insert path (replace-
selection semantics for both blocks and cells).
Three latent production bugs uncovered and fixed at the source:
1. delete_selection's applied flag was wired to the empty replacement's
missing trailing atom, so same-block Backspace-over-selection
over-deleted by one char and left live anchors behind.
2. engine delete_text pushed no undo compensation, making every
selection deletion (cut, type-over, Backspace-over) unrecoverable.
It now pushes the symmetric RestoreText pair.
3. All six engine tombstone pairs (text, blocks, rows, columns, merge
splits, cell style clears) resolved as union-minus-union: once a
restore op existed, delete→undo→redo could never re-delete. They now
resolve chronologically per target in operation order.
13 new tests: 5 clipboard runtime (payload, cut+undo, cell span,
no-payload, newline join), 2 widget regressions (over-delete,
delete-key), 6 engine (delete undo/redo, empty-replace single-step,
block/row/merge/cell-style chronology cycles). nigig-build 704 -> 711,
doc-engine 62 -> 68.
Cell text was plain LWW strings, so style toggles were silently
disabled whenever the caret sat in a table. Cells now carry styled runs
end to end:
- Engine: new `SetTableCellStyle` / `ClearTableCellStyle` /
`RestoreTableCellStyle` ops. Ranges are char-offset snapshots replayed
over the settled LWW cell text in operation order (newer patches win
on overlap, spans clamp when text shrinks but do not stretch when it
grows). Undo tombstones the style op exactly like a merge split,
keeping overlapping later styles intact and undo/redo convergent
across peers. `ProjectedTable::cell_runs` mirrors `cells` — every
projected cell has at least one default-styled run — built by the new
`apply_style_offset_range` helper, with `TableCellRef` addressing
cells in the controller API.
- Layout: `ProjectedTableCellLayout.runs` mirrors the projection runs
for the renderer.
- Renderer: `draw_table_projection` draws cell text run by run through
the same regular/bold/italic/bold-italic pens as block text, advancing
on the shared fixed char grid.
- Widget: `toggle_selection_style` routes by session — a parked cell
cursor styles the whole cell (cell text has no selection ranges yet),
a text selection styles its range — so Ctrl/Cmd+B/I/U and the toolbar
buttons now work in tables. Undo/redo round-trips like any other op.
9 engine tests (split/merge/clamp/overlap/toggle/reject/undo/convergence),
5 widget tests (layout runs, Ctrl+B whole-cell, undo/redo, toolbar path,
empty-cell no-op). doc-engine 53 -> 62, nigig-build 685 -> 690.
The doc README's "cell text carries no style runs yet" gap is closed and
the doc-engine projection invariants document the new semantics.
ProjectionLayoutTree::hit_test's nearest-glyph fallback resolved any
point to a glyph, so taps landing inside tables or advanced nodes never
reached the widget's table_hit_test/node_hit_test branches — production
cell taps silently moved the text caret instead of parking in cells.
The fallback now returns None when the point is inside a table or node
rect (those layers own their taps), while empty-space snapping beside
text still places the caret.
Close the "widget input, selection, mobile gestures" test gap with a
draw-free Area::Rect stub harness (stub_hit_area): tests seed the input
area a GPU frame normally fills and mirror the two platform
pre-dispatch steps (first_mouse_button, committed key focus), so raw
MouseDown/MouseMove and IME TextInput dispatch through event.hits
against a real Cx — exactly like a running app. Covered end-to-end:
hit-dispatch sanity, caret placement from a tap, shift-press selection
extension, drag selection over glyphs, TextInput inserting at the caret
and replacing an active selection, and a cell tap + TextInput whole-cell
round trip. The harness also exposed the test fixture actor drifting
from production's single "local" actor (cross-actor mid-run anchoring
is deterministic but not chronological — now a documented doc-engine
projection invariant); the runtime fixture now uses "local" for
production fidelity.
READMEs: doc runtime-tests section documents the stub harness and the
two bugs it unearthed; roadmap item updated (only the renderer draw
pass remains GPU/Studio-bound); doc-engine projection invariants gain
the per-actor counter boundary bullet.
6 new tests, 675 -> 681.
Closes the roadmap's table merge command item on the CRDT editor
surface with a rectangular cell-range selection model driven from the
keyboard and the workspace toolbar:
- ProjectionSession::cell_selection holds stable anchor/focus row and
column ids; Shift+Arrow at a cell boundary starts the range and steps
the focus cell on an active one, with table-edge clamping, caret
follow, plain-action collapse, and undo-stale self-clearing.
- table_cell_range normalizes the selection against the projected
table; cell_selection_rects renders the highlight over visible cells
only, so merged spans contribute one expanded anchor rect.
- Ctrl/Cmd+M (merge_selected_cells, toolbar Merge) routes through the
engine's MergeTableCells op; undo splits through the symmetric
compensation. cell_range_mergeable rejects ranges overlapping an
existing merge UI-side, where ambiguous nested spans belong.
- Ctrl/Cmd+Shift+M (split_cell_at_cursor, toolbar Split) resolves the
containing merge via merge_at_cell even from a covered cell and
reveals the preserved hidden text; undo re-merges through
RestoreTableMerge.
- Workspace toolbar gains Merge/Split buttons with status-line
guidance (the only split path on touch devices).
Tests: 9 new (4 pure layout: range normalization/staleness, mergeable
rules, merge_at_cell, covered-skip highlight rects; 5 runtime on a real
Cx: shift-span + merge + undo, edge clamp, split + re-merge, plain
collapse, overlap rejection) for 669 total in nigig-build. The
doc-engine README gains the merge/split materialization invariant.
InsertTableRow/InsertTableColumn carried an after anchor on the wire but
materialization ignored it, ordering the row/column vectors by op id only;
an 'insert below X' could land anywhere once ids diverged. Rows and
columns now materialize over the anchor chain with RGA-style sibling
order (counter descending, actor ascending), matching text atoms, so a
newer insert below an existing row renders right after it and peers
converge on the same grid order.
CrdtDocEditor now handles the desktop keyboard surface natively through
doc-engine operations: Ctrl/Cmd+Z/Shift+Z undo-redo via CrdtHistory,
Ctrl/Cmd+B/I/U selection style toggles, arrow-key caret moves with
shift-extend across block boundaries via the step_glyph stream,
selection-aware Backspace/Delete with block-boundary merges, Return
splitting paragraphs or appending table rows, and TextInput that
replaces the active selection at the session caret block.
Engine additions: Operation::SplitBlock/MergeBlocks with symmetric
Compensation pairs; materialization keeps a split parent's trailing
runs as a synthetic child spliced after the parent so peers converge
without a new InsertBlock op; merge_runs coalesces equal style runs.
- layout_projection walks the unified DocumentProjection::order so
advanced nodes interleave with paragraphs and tables at their anchor
positions; block_origins are assigned by index for both paths, with a
blocks-only fallback for projections assembled without the op log
- projected_node_metrics mirrors legacy AdvancedLayout heights, labels
and interactivity per kind (unknown kinds follow the bridge's
EmbeddedWidget mapping: 'Widget: <kind>')
- ProjectionRenderer::draw_node_projection reuses the legacy
draw_advanced_blocks colors; dividers collapse to a centered 1px line;
new draw_node_fill/draw_node_border live fields on CrdtDocEditor
- fix(doc-engine): the after-chain was block-only, so any block anchored
after an advanced node was unreachable during materialization and
vanished from the projection; nodes now participate as chain connectors
- tests: unified-order interleaving, node metrics, node hit test, mixed
table/node stacking, order-less fallback (nigig-build); node-anchored
block materialization and mixed sibling ordering (doc-engine)