diff --git a/crates/apps/doc/doc-engine/README.md b/crates/apps/doc/doc-engine/README.md index c20bc50..70b5138 100644 --- a/crates/apps/doc/doc-engine/README.md +++ b/crates/apps/doc/doc-engine/README.md @@ -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`. diff --git a/crates/apps/doc/doc-engine/src/crdt/document.rs b/crates/apps/doc/doc-engine/src/crdt/document.rs index df37f6b..8321240 100644 --- a/crates/apps/doc/doc-engine/src/crdt/document.rs +++ b/crates/apps/doc/doc-engine/src/crdt/document.rs @@ -175,15 +175,39 @@ impl CrdtDocument { _ => {} } } + let mut row_items: BTreeMap, Vec>> = BTreeMap::new(); + let mut column_items: BTreeMap, Vec>> = 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, items: &BTreeMap, Vec>, out: &mut Vec) { + 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() { diff --git a/crates/apps/doc/doc-engine/tests/materialize.rs b/crates/apps/doc/doc-engine/tests/materialize.rs index 96e23e0..1aececa 100644 --- a/crates/apps/doc/doc-engine/tests/materialize.rs +++ b/crates/apps/doc/doc-engine/tests/materialize.rs @@ -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)]); +} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md index 5bb60ad..df8ac62 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md @@ -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. diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs index a3086f5..6a4248e 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs @@ -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() diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs index 15c68c1..b907341 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs @@ -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 = 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 = 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 { + 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 { + 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 { + 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), + }) +} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs index 3829583..7eaf6d7 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs @@ -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, diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_session.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_session.rs index 4cd77dc..ba34d36 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_session.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_session.rs @@ -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, pub cursor_atom: Option, pub selection_anchor: Option, pub selection_focus: Option, + /// 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, } /// Save wire shared with the legacy editor: a `#MP_CRDT_V1` header line diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs index f3508bb..df6d4d1 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs @@ -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; +}