Cells holding tabs, newlines, CRs, or quotes re-distributed across the
grid on a copy/paste cycle — the documented caveat of every tabular
clipboard milestone. This closes it on both sides:
- table_grid_tsv quotes such fields on the way out (wrapped in double
quotes, inner quotes doubled) via a new quote_tabular_field helper;
plain and empty fields stay raw, so payloads stay byte-compatible
with spreadsheets and plain text editors. The cell-range payload and
the block-span document payload share the one builder, so both
inherit the quoting at once.
- paste_table_payload replaces its split('\n')/split('\t') walk with
split_tabular_payload, an RFC-4180-style tokenizer: quotes open only
at field start (mid-field quotes are literal), doubled quotes read
as one, tabs/newlines/CRs inside quotes are literal field text,
CRLF rows outside quotes keep their tolerance, an unterminated
quote reads to the end as best effort, and a single trailing
newline adds no phantom row (a deliberate empty row survives).
- Caret parking, no-op skipping, empty-field clears, and the one-undo
grouped write semantics of the raw paste milestone are unchanged;
the caret offset in a multi-line value counts the newline too.
Tests: unit coverage for the writer and the tokenizer (every quoting
rule plus the quote/split round-trip property), runtime coverage of
copy quoting, paste restoring embedded tab/newline values verbatim
with one-undo and redo, and an end-to-end copy-cut-paste cycle; a
doc-engine materialize test pins special-character cell text
surviving peer sync and undo/redo verbatim. README caveats updated
and the milestone documented.
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 distribution rides the grouped set_table_cells from
the previous milestone so the rectangle un-pastes in one undo step,
and the caret parks at the last cell the payload touched. Rows or
columns past the table edge clip, CRLF strips per line like
text-block pastes, empty fields clear their targets, and unchanged
cells are skipped so a paste lands neither redundant LWW ops nor
dead group members. Plain payloads keep the whole-cell char splice,
and cells still never spawn blocks.
The pre-existing in-cell paste test asserting the old "newline stays
embedded in the cell" behavior is rewritten to the distribution
semantics; new runtime tests cover the 2x2 distribution with caret
parking and the one-undo/redo round trip, edge clipping without
wrap-around, backward-spanned ranges pasting from their top-left,
CRLF with empty-field clears, and the mobile menu's Paste action
distributing from the pressed cell. Engine integration coverage
replays a grouped multi-cell write into a peer and asserts the
projections converge.
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.
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)