Compare commits

..

2 commits

Author SHA1 Message Date
arena-agent
478c7b33b0 feat(doc): CRDT-native in-cell table editing in CrdtDocEditor
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (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
Taps park a TableCellCursor (stable row/column ids + char offset) inside
cells; typing and Backspace/Delete edit cells through whole-cell
SetTableCell replacements with symmetric undo. Arrows walk cell text in
reading order, hop between cells across rows, and exit into the nearest
text block in unified order at the table edges. Return inserts a row
immediately below the cursor's row and follows with the caret. Block
boundaries upgraded: backspace at a text start after a table enters its
trailing cell, forward-delete at a text end before a table enters its
first cell instead of merging structure. Style toggles stay text-only.

The cell caret draws between rendered characters using the renderer's
shared 6px inset / 7px-per-char advance, and stale cursors clear on
structural undos. Also fixed while wiring the paths: pointer hit tests
and layout-space decorations (selection, handles, carets) now account
for the widget origin, so taps and visuals agree at any dock position.

Runtime integration tests cover in-cell editing with undo restore,
arrow traversal/exits/clamping, and return-inserts-row-below; layout
unit tests cover the char-safe edit helpers, cursor resolution and
clamping, caret geometry, neighbor wrapping, and neighbor_text_block.
2026-07-29 05:06:16 +00:00
arena-agent
9a167f4df3 fix(doc-engine): honor after anchors when ordering table rows and columns
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.
2026-07-29 05:06:16 +00:00
9 changed files with 707 additions and 34 deletions

View file

@ -31,3 +31,11 @@ The next migration replaces legacy document controller ownership with
in `order`, so peers replaying the same op log converge on the same
visible sequence without a new `InsertBlock` op. Undo/redo uses the
symmetric `Compensation::{SplitBlock, MergeBlocks}` pair.
- Table rows and columns order by their `after` anchor chain: inserting
below an existing row/col stays right after it even when peers appended
further rows elsewhere. Siblings sharing one anchor serialize RGA-style
(counter descending, then actor ascending), exactly like text atoms, so
a newer "insert below X" renders before older rows anchored on X.
- Cell text is last-writer-wins by op id: one actor per editing surface
keeps chronological edits winning locally, and peer edits converge
deterministically to the highest `OpId`.

View file

@ -175,15 +175,39 @@ impl CrdtDocument {
_ => {}
}
}
let mut row_items: BTreeMap<String, BTreeMap<Option<String>, Vec<OpId>>> = BTreeMap::new();
let mut column_items: BTreeMap<String, BTreeMap<Option<String>, Vec<OpId>>> = BTreeMap::new();
for op in self.operations.values() {
match op {
Operation::InsertTableRow { id, table, .. } if !deleted_rows.contains(id) => tables.entry(format!("{}:{}", table.actor, table.counter)).or_default().rows.push(format!("{}:{}", id.actor, id.counter)),
Operation::InsertTableColumn { id, table, .. } if !deleted_columns.contains(id) => tables.entry(format!("{}:{}", table.actor, table.counter)).or_default().columns.push(format!("{}:{}", id.actor, id.counter)),
Operation::InsertTableRow { id, table, after } if !deleted_rows.contains(id) => { row_items.entry(format!("{}:{}", table.actor, table.counter)).or_default().entry(after.as_ref().map(|id| format!("{}:{}", id.actor, id.counter))).or_default().push(id.clone()); }
Operation::InsertTableColumn { id, table, after } if !deleted_columns.contains(id) => { column_items.entry(format!("{}:{}", table.actor, table.counter)).or_default().entry(after.as_ref().map(|id| format!("{}:{}", id.actor, id.counter))).or_default().push(id.clone()); }
Operation::SetTableCell { table, row, column, text, .. } | Operation::RestoreTableCell { table, row, column, text, .. } => { let entry = tables.entry(format!("{}:{}", table.actor, table.counter)).or_default(); entry.cells.insert((format!("{}:{}", row.actor, row.counter), format!("{}:{}", column.actor, column.counter)), text.clone()); }
Operation::MergeTableCells { id, table, start_row, start_column, end_row, end_column } if !split_merges.contains(id) => { let entry=tables.entry(format!("{}:{}",table.actor,table.counter)).or_default(); entry.merges.push(ProjectedTableMerge { id:format!("{}:{}",id.actor,id.counter), start_row:format!("{}:{}",start_row.actor,start_row.counter), start_column:format!("{}:{}",start_column.actor,start_column.counter), end_row:format!("{}:{}",end_row.actor,end_row.counter), end_column:format!("{}:{}",end_column.actor,end_column.counter) }); }
_ => {}
}
}
// Rows and columns order by their `after` anchor chain, not by op
// id: inserting below an existing row stays below it even when a
// peer (or the local user) had appended later rows in the mean time.
// Siblings sharing one anchor serialize RGA-style — counter
// DESCENDING, then actor ASCENDING — exactly like text atoms, so a
// newer "insert below X" renders before older rows anchored on X.
fn visit_axis(after: Option<String>, items: &BTreeMap<Option<String>, Vec<OpId>>, out: &mut Vec<String>) {
if let Some(ids) = items.get(&after) {
let mut ids = ids.clone();
ids.sort_by(|a, b| b.counter.cmp(&a.counter).then_with(|| a.actor.cmp(&b.actor)));
for id in ids {
out.push(format!("{}:{}", id.actor, id.counter));
visit_axis(Some(format!("{}:{}", id.actor, id.counter)), items, out);
}
}
}
for (table_id, items) in &row_items {
if let Some(table) = tables.get_mut(table_id) { visit_axis(None, items, &mut table.rows); }
}
for (table_id, items) in &column_items {
if let Some(table) = tables.get_mut(table_id) { visit_axis(None, items, &mut table.columns); }
}
for (id, table) in &mut tables { table.id = id.clone(); }
let mut nodes = Vec::new();
for op in self.operations.values() {

View file

@ -1,3 +1,4 @@
use doc_engine::controller::DocumentController;
use doc_engine::crdt::{CrdtDocument, OpId, Operation};
#[test]
fn text_materializes_into_its_block() {
@ -503,3 +504,27 @@ fn merge_undo_resplits_at_the_recorded_offset() {
assert_eq!(c.projection.blocks.len(),1);
assert_eq!(c.projection.blocks[0].text,"helloworld");
}
/// Rows anchor on `after`: inserting below an existing row lands right
/// after it (newest sibling first), regardless of insertion chronology.
#[test]
fn table_rows_follow_after_anchor_chain_with_rga_sibling_order() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r1=c.insert_table_row("a",t.clone(),None).unwrap();
let r2=c.insert_table_row("a",t.clone(),Some(r1.clone())).unwrap();
let mid=c.insert_table_row("a",t.clone(),Some(r1.clone())).unwrap();
let table=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)];
let fmt=|id:&OpId| format!("{}:{}",id.actor,id.counter);
assert_eq!(table.rows,vec![fmt(&r1),fmt(&mid),fmt(&r2)]);
}
#[test]
fn table_columns_follow_after_anchor_chain() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let c1=c.insert_table_column("a",t.clone(),None).unwrap();
let c2=c.insert_table_column("a",t.clone(),Some(c1.clone())).unwrap();
let mid=c.insert_table_column("a",t.clone(),Some(c1.clone())).unwrap();
let table=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)];
let fmt=|id:&OpId| format!("{}:{}",id.actor,id.counter);
assert_eq!(table.columns,vec![fmt(&c1),fmt(&mid),fmt(&c2)]);
}

View file

@ -509,6 +509,7 @@ collaboration-safe acknowledged frontier.
- [x] CRDT-native table rendering
- [x] CRDT-native advanced node rendering
- [x] CRDT-native keyboard editing
- [x] CRDT-native in-cell table editing
- [x] Switch workspace DSL to CrdtDocEditor
- [x] CrdtDocEditor engine installation API
@ -742,3 +743,48 @@ do not compile today. `TextInput`/`FingerDown` events arrive through
remains covered by the projection layout/session unit tests until a
Studio runner exists. The keyboard, touch, and frame-clock dispatch is
direct (no area hit gate) and fully covered by the runtime tests.
## CRDT-native in-cell table editing
`CrdtDocEditor` now edits table content CRDT-natively on top of the
`ProjectedTableLayout` geometry:
- Tapping (desktop `FingerDown`, mobile short tap) inside a cell parks a
`TableCellCursor` (table/row/column ids + char offset) at the end of the
cell's text. Text taps restore the text caret and clear the cell cursor.
- Typing and Backspace/Delete edit inside the cell through whole-cell
`SetTableCell` replacements — the legacy `ReplaceTableCell` semantics —
with undo restoring the prior cell text through the symmetric
compensation. Char offsets are Unicode-scalar safe.
- Arrows walk the cell text in reading order and hop between cells
(wrapping across rows); at the table edges the caret exits into the
nearest text block in unified order (`neighbor_text_block` skips
advanced nodes), landing on its boundary glyph. Backspace at the start
of a paragraph following a table no longer dead-ends: it enters the
table's trailing cell; forward-Delete at a text end before a table
enters its first cell instead of merging table structure into text.
- Return inside a cell inserts a row immediately below the cursor's row
and moves the caret into the same column of the new row.
- The cell caret draws between rendered characters using the shared
6px inset / 7px-per-char convention; a stale cursor (its table vanished
in an undo) clears itself on the next frame. Style toggles stay
text-only (cell text carries no style runs yet).
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, and
Return-inserts-row-below; pure layout tests cover the edit helpers, cell
cursor resolution/clamping, caret geometry, neighbor wrapping, and
`neighbor_text_block` skipping.

View file

@ -5,11 +5,13 @@ use makepad_widgets::makepad_platform::event::TouchState;
// in a `#[rust]` field and reject it with "Unexpected field form".
use doc_engine::controller::DocumentController;
use crate::construction_frame::pages::workspace::doc::projection_layout::{
block_glyph_offset, glyph_index_of, layout_projection, parse_op_id, step_glyph,
selection_handles as compute_selection_handles, word_atom_range, SelectionHandles,
block_glyph_offset, cell_text_backspace, cell_text_delete, cell_text_insert, glyph_index_of,
layout_projection, neighbor_cell, neighbor_text_block, parse_op_id, step_glyph,
selection_handles as compute_selection_handles, table_cell_caret, table_cell_cursor_at,
table_cell_position, table_cell_text, word_atom_range, SelectionHandles,
};
use crate::construction_frame::pages::workspace::doc::projection_renderer::ProjectionRenderer;
use crate::construction_frame::pages::workspace::doc::projection_session::{crdt_engine_from_saved, crdt_save_wire, ProjectionSession};
use crate::construction_frame::pages::workspace::doc::projection_session::{crdt_engine_from_saved, crdt_save_wire, ProjectionSession, TableCellCursor};
use crate::construction_frame::pages::workspace::doc::{
MobileGestureAction, MobileGestureRouter, MobileGestureState,
};
@ -228,8 +230,26 @@ impl CrdtDocEditor {
let index = self.engine.projection.blocks.iter().position(|candidate| candidate.id == id).unwrap_or(0);
if index == 0 { return; }
let previous = &self.engine.projection.blocks[index - 1];
if previous.kind == "table" { return; }
let previous_id_string = previous.id.clone();
if previous.kind == "table" {
// A table cannot merge into text; land the caret at the
// end of its trailing cell instead.
let layout = layout_projection(&self.engine.projection);
if let Some(table_index) = layout.tables.iter().position(|table| table.block_id == previous_id_string) {
let table = &layout.tables[table_index];
if table.rows > 0 && table.cols > 0 {
let (row, col) = (table.rows - 1, table.cols - 1);
let len = table.cell(row, col).map(|cell| cell.text.chars().count()).unwrap_or(0);
if let Some(cursor) = table_cell_cursor_at(&layout, &self.engine.projection, table_index, row, col, len) {
self.session.cell_cursor = Some(cursor);
self.session.cursor_block = None;
self.session.cursor_atom = None;
self.redraw(cx);
}
}
}
return;
}
let seam = previous.text.chars().count();
let Some(previous_id) = parse_op_id(&previous_id_string) else { return; };
if self.engine.merge_block_after("local", previous_id.clone()) {
@ -266,7 +286,25 @@ impl CrdtDocEditor {
if projected.kind == "table" { return; }
let offset = self.cursor_offset_in(&block, &projected);
if offset >= projected.text_atoms.len() {
// Block end: fold the next block into this one.
// Block end: enter the following table instead of merging it
// away; otherwise fold the next text block into this one.
let id = format!("{}:{}", block.actor, block.counter);
let index = self.engine.projection.blocks.iter().position(|candidate| candidate.id == id).unwrap_or(0);
if let Some(next) = self.engine.projection.blocks.get(index + 1) {
if next.kind == "table" {
let next_id = next.id.clone();
let layout = layout_projection(&self.engine.projection);
if let Some(table_index) = layout.tables.iter().position(|table| table.block_id == next_id) {
if let Some(cursor) = table_cell_cursor_at(&layout, &self.engine.projection, table_index, 0, 0, 0) {
self.session.cell_cursor = Some(cursor);
self.session.cursor_block = None;
self.session.cursor_atom = None;
self.redraw(cx);
}
}
return;
}
}
if self.engine.merge_block_after("local", block) {
self.sanitize_cursor();
self.redraw(cx);
@ -327,12 +365,18 @@ impl CrdtDocEditor {
if ke.modifiers.shift { self.redo(cx); } else { self.undo(cx); }
return;
}
KeyCode::KeyB => { self.toggle_selection_style(cx, "bold"); return; }
KeyCode::KeyI => { self.toggle_selection_style(cx, "italic"); return; }
KeyCode::KeyU => { self.toggle_selection_style(cx, "underline"); return; }
// Cell text has no style runs yet; style toggles stay
// text-selection-only while the caret lives in a table.
KeyCode::KeyB => { if self.session.cell_cursor.is_none() { self.toggle_selection_style(cx, "bold"); } return; }
KeyCode::KeyI => { if self.session.cell_cursor.is_none() { self.toggle_selection_style(cx, "italic"); } return; }
KeyCode::KeyU => { if self.session.cell_cursor.is_none() { self.toggle_selection_style(cx, "underline"); } return; }
_ => {}
}
}
if self.session.cell_cursor.is_some() {
self.handle_cell_key_down(cx, ke);
return;
}
match ke.key_code {
KeyCode::ArrowLeft => self.move_selection_edge(cx, false, ke.modifiers.shift),
KeyCode::ArrowRight => self.move_selection_edge(cx, true, ke.modifiers.shift),
@ -343,6 +387,115 @@ impl CrdtDocEditor {
}
}
/// Keyboard surface while the caret lives in a table cell: arrows walk
/// the cell text in reading order and exit to the surrounding text at
/// the table edges, Backspace/Delete edit inside the cell, and Return
/// inserts a row below the cursor's row.
fn handle_cell_key_down(&mut self, cx: &mut Cx, ke: &KeyEvent) {
match ke.key_code {
KeyCode::ArrowLeft => self.cell_arrow(cx, false),
KeyCode::ArrowRight => self.cell_arrow(cx, true),
KeyCode::Backspace => self.cell_backspace(cx),
KeyCode::Delete => self.cell_delete(cx),
KeyCode::ReturnKey => self.cell_newline_row(cx),
_ => {}
}
}
fn cell_arrow(&mut self, cx: &mut Cx, forward: bool) {
let Some(mut cursor) = self.session.cell_cursor.clone() else { return; };
let layout = layout_projection(&self.engine.projection);
let Some((table_index, row, col)) = table_cell_position(&layout, &self.engine.projection, &cursor) else {
self.session.cell_cursor = None;
return;
};
let table = &layout.tables[table_index];
let text_len = table.cell(row, col).map(|cell| cell.text.chars().count()).unwrap_or(0);
if forward {
if cursor.offset < text_len {
cursor.offset += 1;
self.session.cell_cursor = Some(cursor);
} else if let Some((next_row, next_col)) = neighbor_cell(table.rows, table.cols, row, col, true) {
if let Some(next) = table_cell_cursor_at(&layout, &self.engine.projection, table_index, next_row, next_col, 0) {
self.session.cell_cursor = Some(next);
}
} else {
self.exit_cell_to_text(cx, true);
return;
}
} else if cursor.offset > 0 {
cursor.offset -= 1;
self.session.cell_cursor = Some(cursor);
} else if let Some((prev_row, prev_col)) = neighbor_cell(table.rows, table.cols, row, col, false) {
let prev_len = layout.tables[table_index].cell(prev_row, prev_col).map(|cell| cell.text.chars().count()).unwrap_or(0);
if let Some(prev) = table_cell_cursor_at(&layout, &self.engine.projection, table_index, prev_row, prev_col, prev_len) {
self.session.cell_cursor = Some(prev);
}
} else {
self.exit_cell_to_text(cx, false);
return;
}
self.redraw(cx);
}
/// Leaves the table for the nearest text block in unified order,
/// landing the caret on that block's first (forward) or last (backward)
/// glyph. Document edges clamp the caret inside the table.
fn exit_cell_to_text(&mut self, cx: &mut Cx, forward: bool) {
let Some(cursor) = self.session.cell_cursor.clone() else { return; };
let table_id = format!("{}:{}", cursor.table.actor, cursor.table.counter);
let Some(block) = neighbor_text_block(&self.engine.projection, &table_id, forward) else { return; };
let layout = layout_projection(&self.engine.projection);
let glyph = if forward {
layout.glyphs.iter().find(|glyph| glyph.block == block)
} else {
layout.glyphs.iter().rev().find(|glyph| glyph.block == block)
};
let atom = glyph.map(|glyph| glyph.atom.clone());
self.session.cell_cursor = None;
self.session.cursor_block = Some(block);
self.session.cursor_atom = atom;
self.session.selection_anchor = None;
self.session.selection_focus = None;
self.redraw(cx);
}
fn cell_backspace(&mut self, cx: &mut Cx) {
let Some(mut cursor) = self.session.cell_cursor.clone() else { return; };
let text = table_cell_text(&self.engine.projection, &cursor);
let Some((new_text, new_offset)) = cell_text_backspace(&text, cursor.offset) else { return; };
if self.engine.set_table_cell("local", cursor.table.clone(), cursor.row.clone(), cursor.column.clone(), new_text) {
cursor.offset = new_offset;
self.session.cell_cursor = Some(cursor);
self.redraw(cx);
}
}
fn cell_delete(&mut self, cx: &mut Cx) {
let Some(mut cursor) = self.session.cell_cursor.clone() else { return; };
let text = table_cell_text(&self.engine.projection, &cursor);
let Some((new_text, new_offset)) = cell_text_delete(&text, cursor.offset) else { return; };
if self.engine.set_table_cell("local", cursor.table.clone(), cursor.row.clone(), cursor.column.clone(), new_text) {
cursor.offset = new_offset;
self.session.cell_cursor = Some(cursor);
self.redraw(cx);
}
}
/// Return inside a cell inserts a fresh row below the cursor's row and
/// lands the caret in the same column of the new row.
fn cell_newline_row(&mut self, cx: &mut Cx) {
let Some(cursor) = self.session.cell_cursor.clone() else { return; };
let Some(new_row) = self.engine.insert_table_row("local", cursor.table.clone(), Some(cursor.row.clone())) else { return; };
self.session.cell_cursor = Some(TableCellCursor {
table: cursor.table,
row: new_row,
column: cursor.column,
offset: 0,
});
self.redraw(cx);
}
fn move_selection_edge(&mut self, cx: &mut Cx, forward: bool, shift: bool) {
let Some(current) = self.session.cursor_atom.clone() else {
let layout = layout_projection(&self.engine.projection);
@ -367,6 +520,15 @@ impl CrdtDocEditor {
self.session.cursor_atom = Some(glyph.atom.clone());
self.redraw(cx);
}
/// Maps an absolute pointer position into the layout frame shared by
/// glyphs, table geometry, and selection handles. The renderer draws
/// layout-space content at the widget's own origin, so subtracting the
/// drawn area's rect makes hit tests match the rendered pixels at any
/// dock position or scroll offset.
fn layout_point(&self, cx: &Cx, abs: DVec2) -> DVec2 {
abs - self.draw_bg.area().rect(cx).pos
}
}
impl Widget for CrdtDocEditor {
@ -403,7 +565,7 @@ impl Widget for CrdtDocEditor {
if let Event::TouchUpdate(tu) = event {
self.saw_touch = true;
if let Some(touch) = tu.touches.first() {
let point = touch.abs;
let point = self.layout_point(cx, touch.abs);
match touch.state {
TouchState::Start => {
let layout = layout_projection(&self.engine.projection);
@ -443,6 +605,18 @@ impl Widget for CrdtDocEditor {
self.session.cursor_atom = Some(glyph.atom.clone());
self.session.selection_anchor = None;
self.session.selection_focus = None;
self.session.cell_cursor = None;
} else if let Some((table_index, row, col)) = layout.table_hit_test(point) {
// Short tap on a cell: passive caret like
// the text path, no IME.
let len = layout.tables[table_index].cell(row, col).map(|cell| cell.text.chars().count()).unwrap_or(0);
if let Some(cursor) = table_cell_cursor_at(&layout, &self.engine.projection, table_index, row, col, len) {
self.session.cell_cursor = Some(cursor);
self.session.cursor_block = None;
self.session.cursor_atom = None;
self.session.selection_anchor = None;
self.session.selection_focus = None;
}
}
}
self.redraw(cx);
@ -457,36 +631,62 @@ impl Widget for CrdtDocEditor {
Hit::FingerDown(fe) if fe.is_primary_hit() && !self.saw_touch => {
cx.set_key_focus(self.draw_bg.area());
let layout = layout_projection(&self.engine.projection);
if let Some(glyph) = layout.hit_test(fe.abs) {
let point = self.layout_point(cx, fe.abs);
if let Some(glyph) = layout.hit_test(point) {
if !fe.modifiers.shift { self.session.selection_anchor = Some(glyph.atom.clone()); }
self.session.cursor_block = Some(glyph.block.clone());
self.session.cursor_atom = Some(glyph.atom.clone());
self.session.selection_focus = Some(glyph.atom.clone());
self.session.cell_cursor = None;
} else if let Some((table_index, row, col)) = layout.table_hit_test(point) {
// Tap into a cell: caret parks at the end of its text,
// selection and text caret clear out.
let len = layout.tables[table_index].cell(row, col).map(|cell| cell.text.chars().count()).unwrap_or(0);
if let Some(cursor) = table_cell_cursor_at(&layout, &self.engine.projection, table_index, row, col, len) {
self.session.cell_cursor = Some(cursor);
self.session.cursor_block = None;
self.session.cursor_atom = None;
self.session.selection_anchor = None;
self.session.selection_focus = None;
}
}
cx.show_text_ime(self.draw_bg.area(), fe.abs);
}
Hit::FingerMove(fe) if !self.saw_touch => {
if self.session.selection_anchor.is_some() {
let layout = layout_projection(&self.engine.projection);
if let Some(glyph) = layout.hit_test(fe.abs) { self.session.selection_focus = Some(glyph.atom.clone()); self.redraw(cx); }
let point = self.layout_point(cx, fe.abs);
if let Some(glyph) = layout.hit_test(point) { self.session.selection_focus = Some(glyph.atom.clone()); self.session.cell_cursor = None; self.redraw(cx); }
}
}
Hit::TextInput(TextInputEvent { ref input, .. }) if !input.is_empty() => {
// Typing over a selection replaces it; the insertion then
// lands in the caret block instead of always the first.
self.delete_selection(cx);
let block_ref = self
.session
.cursor_block
.clone()
.or_else(|| self.engine.projection.blocks.first().and_then(|block| parse_op_id(&block.id)));
if let Some(block_id) = block_ref {
let after = self.session.cursor_atom.clone();
if let Some(last_atom) = self.engine.insert_text("local", block_id.clone(), after, input.clone()) {
self.session.cursor_atom = Some(last_atom);
self.session.cursor_block = Some(block_id);
if let Some(mut cursor) = self.session.cell_cursor.clone() {
// In-cell insertion replaces the whole cell text via
// SetTableCell (undo restores the prior cell text).
let text = table_cell_text(&self.engine.projection, &cursor);
let (new_text, new_offset) = cell_text_insert(&text, cursor.offset, input);
if self.engine.set_table_cell("local", cursor.table.clone(), cursor.row.clone(), cursor.column.clone(), new_text) {
cursor.offset = new_offset;
self.session.cell_cursor = Some(cursor);
self.redraw(cx);
}
} else {
// Typing over a selection replaces it; the insertion then
// lands in the caret block instead of always the first.
self.delete_selection(cx);
let block_ref = self
.session
.cursor_block
.clone()
.or_else(|| self.engine.projection.blocks.first().and_then(|block| parse_op_id(&block.id)));
if let Some(block_id) = block_ref {
let after = self.session.cursor_atom.clone();
if let Some(last_atom) = self.engine.insert_text("local", block_id.clone(), after, input.clone()) {
self.session.cursor_atom = Some(last_atom);
self.session.cursor_block = Some(block_id);
self.redraw(cx);
}
}
}
}
_ => {}
@ -502,11 +702,16 @@ impl Widget for CrdtDocEditor {
ProjectionRenderer::draw_text_projection(cx, &mut self.draw_text, &mut self.draw_bold_text, &mut self.draw_italic_text, &mut self.draw_bold_italic_text, &self.engine.projection, &layout, rect.pos + dvec2(12.0, 12.0));
ProjectionRenderer::draw_table_projection(cx, &mut self.draw_table_border, &mut self.draw_text, &layout, rect.pos + dvec2(12.0, 12.0));
ProjectionRenderer::draw_node_projection(cx, &mut self.draw_node_fill, &mut self.draw_node_border, &mut self.draw_text, &layout, rect.pos + dvec2(12.0, 12.0));
// Layout-space decorations (selection, handles, carets) draw
// through the same widget-origin offset the renderer uses, so they
// overlay the glyphs at any dock position or scroll offset.
if let (Some(anchor), Some(focus)) = (self.session.selection_anchor.as_ref(), self.session.selection_focus.as_ref()) {
let mut selected = false;
for glyph in &layout.glyphs {
if &glyph.atom == anchor || &glyph.atom == focus { selected = !selected; }
if selected { self.draw_selection.draw_abs(cx, glyph.rect); }
if selected {
self.draw_selection.draw_abs(cx, Rect { pos: rect.pos + glyph.rect.pos, size: glyph.rect.size });
}
}
}
// Long-press selections expose draggable start/end handles. The
@ -515,15 +720,25 @@ impl Widget for CrdtDocEditor {
if let (Some(anchor), Some(focus)) = selection {
self.selection_handles = compute_selection_handles(&layout, &anchor, &focus);
if let Some(handles) = &self.selection_handles {
self.draw_selection_handle.draw_abs(cx, handles.start_rect);
self.draw_selection_handle.draw_abs(cx, handles.end_rect);
self.draw_selection_handle.draw_abs(cx, Rect { pos: rect.pos + handles.start_rect.pos, size: handles.start_rect.size });
self.draw_selection_handle.draw_abs(cx, Rect { pos: rect.pos + handles.end_rect.pos, size: handles.end_rect.size });
}
} else {
self.selection_handles = None;
}
if let Some(atom) = self.session.cursor_atom.as_ref() {
if let Some(glyph) = layout.glyphs.iter().find(|glyph| &glyph.atom == atom) {
self.draw_caret.draw_abs(cx, Rect { pos: glyph.rect.pos, size: dvec2(2.0, glyph.rect.size.y) });
self.draw_caret.draw_abs(cx, Rect { pos: rect.pos + glyph.rect.pos, size: dvec2(2.0, glyph.rect.size.y) });
}
}
// In-cell caret; a stale cursor (its table/cell vanished after an
// undo) clears itself here instead of lingering invisible.
if let Some(cell_cursor) = self.session.cell_cursor.clone() {
match table_cell_caret(&layout, &self.engine.projection, &cell_cursor) {
Some(caret) => {
self.draw_caret.draw_abs(cx, Rect { pos: rect.pos + caret.pos, size: caret.size });
}
None => { self.session.cell_cursor = None; }
}
}
DrawStep::done()

View file

@ -495,3 +495,139 @@ pub fn projected_stats(projection: &DocumentProjection) -> (usize, usize) {
}
(words, chars)
}
/// Horizontal advance per character, shared with the projection renderer's
/// text drawing so caret math and glyphs agree.
pub const TEXT_CHAR_ADVANCE: f64 = 7.0;
/// Left inset of text inside a table cell (renderer draws at +6.0).
pub const TABLE_CELL_TEXT_INSET: f64 = 6.0;
use crate::construction_frame::pages::workspace::doc::projection_session::TableCellCursor;
/// Inserts `input` at character `offset` in `text`, returning the new cell
/// text and caret offset. Char-boundary safe (offsets are Unicode scalars,
/// matching the engine's offset convention).
pub fn cell_text_insert(text: &str, offset: usize, input: &str) -> (String, usize) {
let offset = offset.min(text.chars().count());
let byte = text.char_indices().nth(offset).map(|(index, _)| index).unwrap_or(text.len());
let mut out = String::with_capacity(text.len() + input.len());
out.push_str(&text[..byte]);
out.push_str(input);
out.push_str(&text[byte..]);
(out, offset + input.chars().count())
}
/// Deletes the character before `offset` (Backspace inside a cell);
/// `None` at the cell start or past the end, so callers can keep behavior
/// clamped instead of leaking into sibling cells.
pub fn cell_text_backspace(text: &str, offset: usize) -> Option<(String, usize)> {
if offset == 0 { return None; }
let mut chars: Vec<char> = text.chars().collect();
if offset > chars.len() { return None; }
chars.remove(offset - 1);
Some((chars.into_iter().collect(), offset - 1))
}
/// Deletes the character at `offset` (forward Delete inside a cell);
/// `None` at the cell end.
pub fn cell_text_delete(text: &str, offset: usize) -> Option<(String, usize)> {
if offset >= text.chars().count() { return None; }
let mut chars: Vec<char> = text.chars().collect();
chars.remove(offset);
Some((chars.into_iter().collect(), offset))
}
/// Reading-order neighbor of `(row, col)` inside one table, wrapping
/// across rows; `None` at the table edges so callers can exit to the
/// surrounding text blocks.
pub fn neighbor_cell(rows: usize, cols: usize, row: usize, col: usize, forward: bool) -> Option<(usize, usize)> {
if rows == 0 || cols == 0 { return None; }
let index = row * cols + col;
let next = if forward { index.checked_add(1) } else { index.checked_sub(1) }?;
(next < rows * cols).then(|| (next / cols, next % cols))
}
/// Resolves a cell cursor to `(table_index, row, col)` layout indices,
/// validating every id against the live projection. Stale cursors (e.g.
/// after an undo removed the table) return `None` so callers can clear.
pub fn table_cell_position(
layout: &ProjectionLayoutTree,
projection: &DocumentProjection,
cursor: &TableCellCursor,
) -> Option<(usize, usize, usize)> {
let table_id = format!("{}:{}", cursor.table.actor, cursor.table.counter);
let table_index = layout.tables.iter().position(|table| table.block_id == table_id)?;
let projected = projection.tables.get(&table_id)?;
let row_key = format!("{}:{}", cursor.row.actor, cursor.row.counter);
let column_key = format!("{}:{}", cursor.column.actor, cursor.column.counter);
let row = projected.rows.iter().position(|row| row == &row_key)?;
let col = projected.columns.iter().position(|col| col == &column_key)?;
Some((table_index, row, col))
}
/// Current text of the cursor's cell from the projection (raw cell text;
/// unlike the layout, merge-covered cells keep their stored value).
pub fn table_cell_text(projection: &DocumentProjection, cursor: &TableCellCursor) -> String {
let table_id = format!("{}:{}", cursor.table.actor, cursor.table.counter);
let key = (
format!("{}:{}", cursor.row.actor, cursor.row.counter),
format!("{}:{}", cursor.column.actor, cursor.column.counter),
);
projection.tables.get(&table_id).and_then(|table| table.cells.get(&key)).cloned().unwrap_or_default()
}
/// Builds a cell cursor from layout indices — the tap-to-cell entry point
/// and the arrow-key cell hop. `offset` is clamped to the cell text.
pub fn table_cell_cursor_at(
layout: &ProjectionLayoutTree,
projection: &DocumentProjection,
table_index: usize,
row: usize,
col: usize,
offset: usize,
) -> Option<TableCellCursor> {
let table = layout.tables.get(table_index)?;
let projected = projection.tables.get(&table.block_id)?;
let row_id = projected.rows.get(row)?;
let column_id = projected.columns.get(col)?;
let text_len = layout.tables.get(table_index)?.cell(row, col).map(|cell| cell.text.chars().count()).unwrap_or(0);
Some(TableCellCursor {
table: parse_op_id(&table.block_id)?,
row: parse_op_id(row_id)?,
column: parse_op_id(column_id)?,
offset: offset.min(text_len),
})
}
/// First text-block id after (`forward`) or before the table in the
/// projection's unified order; `None` at document edges. Advanced nodes in
/// the order are skipped, so the caret always lands on editable text.
pub fn neighbor_text_block(projection: &DocumentProjection, table_id: &str, forward: bool) -> Option<OpId> {
let index = projection.order.iter().position(|id| id == table_id)?;
let is_text = |id: &String| {
projection.blocks.iter().find(|block| &block.id == id).filter(|block| block.kind != "table")
};
let candidate = if forward {
projection.order[index + 1..].iter().find_map(is_text)
} else {
projection.order[..index].iter().rev().find_map(is_text)
}?;
parse_op_id(&candidate.id)
}
/// Layout-space 2px caret rect for a cell cursor, using the renderer's
/// text inset and advance so it lands between the drawn characters.
pub fn table_cell_caret(
layout: &ProjectionLayoutTree,
projection: &DocumentProjection,
cursor: &TableCellCursor,
) -> Option<Rect> {
let (table_index, row, col) = table_cell_position(layout, projection, cursor)?;
let cell = layout.tables.get(table_index)?.cell(row, col)?;
let text_len = cell.text.chars().count();
let x = cell.rect.pos.x + TABLE_CELL_TEXT_INSET + cursor.offset.min(text_len) as f64 * TEXT_CHAR_ADVANCE;
Some(Rect {
pos: dvec2(x, cell.rect.pos.y + (cell.rect.size.y - 18.0).max(0.0) * 0.5 + 1.0),
size: dvec2(2.0, 16.0),
})
}

View file

@ -1,7 +1,7 @@
use doc_engine::projection::DocumentProjection;
use makepad_widgets::*;
use crate::construction_frame::pages::workspace::doc::projection_layout::{
ProjectionLayoutTree, LAYOUT_MARGIN,
ProjectionLayoutTree, LAYOUT_MARGIN, TABLE_CELL_TEXT_INSET, TEXT_CHAR_ADVANCE,
};
pub struct ProjectionRenderer;
@ -53,8 +53,8 @@ impl ProjectionRenderer {
} else {
&mut *regular
};
draw.draw_abs(cx, dvec2(x, y), &run.text);
x += run.text.chars().count() as f64 * 7.0;
draw.draw_abs(cx, dvec2(x, y), &run.text);
x += run.text.chars().count() as f64 * TEXT_CHAR_ADVANCE;
}
}
}
@ -138,7 +138,7 @@ impl ProjectionRenderer {
text.draw_abs(
cx,
dvec2(
rect.pos.x + 6.0,
rect.pos.x + TABLE_CELL_TEXT_INSET,
rect.pos.y + (rect.size.y - 18.0).max(0.0) * 0.5,
),
&cell.text,

View file

@ -1,11 +1,25 @@
use doc_engine::crdt::OpId;
/// Cursor inside one projected table cell. `offset` is a character index
/// into the cell's text; the engine replaces whole-cell text per edit
/// (`SetTableCell`), so a character column is the natural caret model.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TableCellCursor {
pub table: OpId,
pub row: OpId,
pub column: OpId,
pub offset: usize,
}
#[derive(Clone, Debug, Default)]
pub struct ProjectionSession {
pub cursor_block: Option<OpId>,
pub cursor_atom: Option<OpId>,
pub selection_anchor: Option<OpId>,
pub selection_focus: Option<OpId>,
/// Present while the caret lives in a table cell; the text-block
/// cursor fields are cleared when this is set and vice versa.
pub cell_cursor: Option<TableCellCursor>,
}
/// Save wire shared with the legacy editor: a `#MP_CRDT_V1` header line

View file

@ -644,3 +644,208 @@ fn runtime_backspace_on_empty_split_tail_merges_with_previous() {
assert_eq!(block_texts(&editor), ["abcd"]);
assert!(editor.session.cursor_atom.is_some(), "caret re-anchored on a live glyph after merge");
}
// == CRDT-native in-cell table editing =====================================
use super::projection_layout::{
cell_text_backspace, cell_text_delete, cell_text_insert, neighbor_cell, neighbor_text_block,
table_cell_caret, table_cell_cursor_at, table_cell_position, table_cell_text,
};
use super::projection_session::TableCellCursor;
#[test]
fn cell_text_edit_helpers_are_char_boundary_safe() {
let (inserted, offset) = cell_text_insert("aé日c", 2, "XY");
assert_eq!(inserted, "aéXY日c");
assert_eq!(offset, 4);
// Insert at the end appends.
let (appended, offset) = cell_text_insert("ab", 99, "z");
assert_eq!(appended, "abz");
assert_eq!(offset, 2 + 1);
assert_eq!(cell_text_backspace("aé日c", 2), Some(("a日c".to_string(), 1)));
assert_eq!(cell_text_backspace("ab", 0), None);
assert_eq!(cell_text_backspace("ab", 99), None);
assert_eq!(cell_text_delete("aé日c", 1), Some(("a日c".to_string(), 1)));
assert_eq!(cell_text_delete("ab", 2), None);
}
#[test]
fn neighbor_cell_wraps_rows_in_reading_order_and_clamps() {
assert_eq!(neighbor_cell(2, 2, 0, 0, true), Some((0, 1)));
assert_eq!(neighbor_cell(2, 2, 0, 1, true), Some((1, 0)));
assert_eq!(neighbor_cell(2, 2, 1, 1, true), None);
assert_eq!(neighbor_cell(2, 2, 1, 0, false), Some((0, 1)));
assert_eq!(neighbor_cell(2, 2, 0, 0, false), None);
assert_eq!(neighbor_cell(0, 0, 0, 0, true), None);
}
#[test]
fn table_cell_cursor_lifecycle_validates_ids_against_the_projection() {
let (engine, _table, row0, _row1, col0, _col1) = projection_table_engine();
let layout = layout_projection(&engine.projection);
// Tap into (row 0, col 1) with a caret past the text end clamps to the
// text length and round-trips back to the same indices.
let cursor = table_cell_cursor_at(&layout, &engine.projection, 0, 0, 1, 99).expect("cursor");
assert_eq!(cursor.row, row0);
assert_eq!(cursor.column, layout_cell_col1(&engine));
assert_eq!(cursor.offset, 4, "clamped to len(\"unit\")");
assert_eq!(table_cell_position(&layout, &engine.projection, &cursor), Some((0, 0, 1)));
assert_eq!(table_cell_text(&engine.projection, &cursor), "unit");
// Caret: cell (172,12) origin + 6 inset + 4 chars x 7 advance.
let caret = table_cell_caret(&layout, &engine.projection, &cursor).expect("caret");
assert_eq!(caret.pos, dvec2(12.0 + 160.0 + 6.0 + 28.0, 18.0));
assert_eq!(caret.size, dvec2(2.0, 16.0));
// A cursor pointing at a vanished row fails validation.
let stale = TableCellCursor { table: cursor.table.clone(), row: OpId { actor: "ghost".into(), counter: 999 }, column: cursor.column.clone(), offset: 0 };
assert_eq!(table_cell_position(&layout, &engine.projection, &stale), None);
assert_eq!(table_cell_caret(&layout, &engine.projection, &stale), None);
let _ = col0;
}
fn layout_cell_col1(engine: &CrdtController) -> OpId {
let table = engine.projection.tables.values().next().expect("table");
parse_op_id(&table.columns[1]).expect("col id")
}
#[test]
fn neighbor_text_block_skips_nodes_and_tables_in_unified_order() {
let mut engine = CrdtController::default();
let first = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", first.clone(), None, "lead");
let table = engine.insert_table("t", Some(first.clone())).unwrap();
let node = engine.insert_node("t", None, Some(table.clone()), "image", "").unwrap();
let last = engine.insert_block("t", Some(node), "paragraph").unwrap();
engine.insert_text("t", last.clone(), None, "tail");
let table_id = format!("{}:{}", table.actor, table.counter);
assert_eq!(neighbor_text_block(&engine.projection, &table_id, true), Some(last));
assert_eq!(neighbor_text_block(&engine.projection, &table_id, false), Some(first));
// Document edges: no text beyond the first/last block.
let first_id = engine.projection.blocks[0].id.clone();
let last_id = engine.projection.blocks.last().unwrap().id.clone();
assert_eq!(neighbor_text_block(&engine.projection, &last_id, true), None);
assert_eq!(neighbor_text_block(&engine.projection, &first_id, false), None);
}
/// Editor seeded with an arbitrary prebuilt engine, for cell-cursor tests.
fn crdt_editor_with_engine(engine: CrdtController) -> (Cx, CrdtDocEditor) {
let mut cx = Cx::new(Box::new(|_, _| {}));
let editor = {
let mut host = ();
let mut std = ();
let mut vm = ScriptVm {
host: &mut host,
std: &mut std,
bx: Box::new(ScriptVmBase::new()),
};
let mut editor = CrdtDocEditor::script_new(&mut vm);
editor.set_engine(&mut cx, engine);
editor
};
(cx, editor)
}
/// Engine with a table between two paragraphs ("lead" / "tail") and
/// seeded cells "a" and "bc". Uses the widget's own actor ("local") so
/// per-cell last-writer-wins order matches production chronology.
fn table_editor_engine() -> (CrdtController, OpId, OpId, OpId, OpId) {
let mut engine = CrdtController::default();
let lead = engine.insert_block("local", None, "paragraph").unwrap();
engine.insert_text("local", lead.clone(), None, "lead");
let table = engine.insert_table("local", Some(lead.clone())).unwrap();
let row = engine.insert_table_row("local", table.clone(), None).unwrap();
let col0 = engine.insert_table_column("local", table.clone(), None).unwrap();
let col1 = engine.insert_table_column("local", table.clone(), Some(col0.clone())).unwrap();
engine.set_table_cell("local", table.clone(), row.clone(), col0.clone(), "a");
engine.set_table_cell("local", table.clone(), row.clone(), col1.clone(), "bc");
let tail = engine.insert_block("local", Some(table.clone()), "paragraph").unwrap();
engine.insert_text("local", tail.clone(), None, "tail");
(engine, lead, table, row, tail)
}
fn cell_cursor_in(editor: &CrdtDocEditor, row: usize, col: usize, offset: usize) -> TableCellCursor {
let layout = layout_projection(&editor.engine().projection);
table_cell_cursor_at(&layout, &editor.engine().projection, 0, row, col, offset).expect("cell cursor")
}
#[test]
fn runtime_cell_backspace_edits_cell_and_ctrl_z_restores_it() {
let (mut engine, _lead, table, row, _tail) = table_editor_engine();
let col = engine.projection.tables.values().next().unwrap().columns[0].clone();
let col = parse_op_id(&col).unwrap();
engine.history.redo.clear(); // isolates the undo assertion from setup
let (mut cx, mut editor) = crdt_editor_with_engine(engine);
editor.session.cell_cursor = Some(TableCellCursor { table, row, column: col, offset: 1 });
editor.handle_event(&mut cx, &runtime_key_down(KeyCode::Backspace), &mut Scope::empty());
let cursor = editor.session.cell_cursor.clone().expect("still in cell");
assert_eq!(cursor.offset, 0);
assert_eq!(table_cell_text(&editor.engine().projection, &cursor), "");
editor.handle_event(&mut cx, &runtime_key_down_mods(KeyCode::KeyZ, false, true), &mut Scope::empty());
let cursor = editor.session.cell_cursor.clone().expect("still in cell");
assert_eq!(table_cell_text(&editor.engine().projection, &cursor), "a");
}
#[test]
fn runtime_cell_arrows_traverse_cells_and_exit_to_surrounding_text() {
let (engine, lead, _table, _row, tail) = table_editor_engine();
let (mut cx, mut editor) = crdt_editor_with_engine(engine);
// Caret at end of "a": Right hops to the next cell's start.
editor.session.cell_cursor = Some(cell_cursor_in(&editor, 0, 0, 1));
editor.handle_event(&mut cx, &runtime_key_down(KeyCode::ArrowRight), &mut Scope::empty());
let cursor = editor.session.cell_cursor.clone().expect("in cell");
assert_eq!(cursor.offset, 0);
assert_eq!(table_cell_position(&layout_projection(&editor.engine().projection), &editor.engine().projection, &cursor), Some((0, 0, 1)));
// Walk "bc" to its end, then exit forward into the "tail" paragraph.
runtime_tap(&mut editor, &mut cx, KeyCode::ArrowRight, 3);
assert!(editor.session.cell_cursor.is_none(), "exited the table");
assert_eq!(editor.session.cursor_block, Some(tail));
let layout = layout_projection(&editor.engine().projection);
let first_glyph = layout.glyphs.iter().find(|g| g.block == editor.session.cursor_block.clone().unwrap()).unwrap();
assert_eq!(editor.session.cursor_atom, Some(first_glyph.atom.clone()));
// Re-enter and exit backwards into "lead": caret lands on its last glyph.
editor.session.cell_cursor = Some(cell_cursor_in(&editor, 0, 0, 0));
editor.handle_event(&mut cx, &runtime_key_down(KeyCode::ArrowLeft), &mut Scope::empty());
assert!(editor.session.cell_cursor.is_none());
assert_eq!(editor.session.cursor_block, Some(lead));
let layout = layout_projection(&editor.engine().projection);
let last_glyph = layout.glyphs.iter().rev().find(|g| g.block == editor.session.cursor_block.clone().unwrap()).unwrap();
assert_eq!(editor.session.cursor_atom, Some(last_glyph.atom.clone()));
}
#[test]
fn runtime_cell_arrows_clamp_inside_a_table_at_document_edges() {
let mut engine = CrdtController::default();
let table = engine.insert_table("t", None).unwrap();
let row = engine.insert_table_row("t", table.clone(), None).unwrap();
let col = engine.insert_table_column("t", table.clone(), None).unwrap();
engine.set_table_cell("t", table.clone(), row, col, "x");
let (mut cx, mut editor) = crdt_editor_with_engine(engine);
editor.session.cell_cursor = Some(cell_cursor_in(&editor, 0, 0, 0));
editor.handle_event(&mut cx, &runtime_key_down(KeyCode::ArrowLeft), &mut Scope::empty());
assert!(editor.session.cell_cursor.is_some(), "no surrounding text: caret stays in the table");
// Same at the far edge.
editor.session.cell_cursor = Some(cell_cursor_in(&editor, 0, 0, 1));
editor.handle_event(&mut cx, &runtime_key_down(KeyCode::ArrowRight), &mut Scope::empty());
assert!(editor.session.cell_cursor.is_some());
}
#[test]
fn runtime_cell_return_inserts_row_below_and_moves_caret_into_it() {
let (engine, _lead, table, row, _tail) = table_editor_engine();
let (mut cx, mut editor) = crdt_editor_with_engine(engine);
editor.session.cell_cursor = Some(cell_cursor_in(&editor, 0, 0, 0));
editor.handle_event(&mut cx, &runtime_key_down(KeyCode::ReturnKey), &mut Scope::empty());
let table_id = format!("{}:{}", table.actor, table.counter);
let projected = &editor.engine().projection.tables[&table_id];
assert_eq!(projected.rows.len(), 2);
let cursor = editor.session.cell_cursor.clone().expect("caret in new row");
let new_row_key = format!("{}:{}", cursor.row.actor, cursor.row.counter);
assert_eq!(projected.rows[1], new_row_key, "new row inserted below the cursor's row");
assert_eq!(cursor.offset, 0);
let _ = row;
}