From 9a167f4df3367141aa59abc67cf6525c99faeda1 Mon Sep 17 00:00:00 2001 From: arena-agent Date: Wed, 29 Jul 2026 05:03:38 +0000 Subject: [PATCH 1/2] 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. --- crates/apps/doc/doc-engine/README.md | 8 ++++++ .../apps/doc/doc-engine/src/crdt/document.rs | 28 +++++++++++++++++-- .../apps/doc/doc-engine/tests/materialize.rs | 25 +++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) 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)]); +} From 478c7b33b0f591c1e1f201c8c352f183600c0d78 Mon Sep 17 00:00:00 2001 From: arena-agent Date: Wed, 29 Jul 2026 05:03:38 +0000 Subject: [PATCH 2/2] feat(doc): CRDT-native in-cell table editing in CrdtDocEditor 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. --- .../pages/workspace/doc/README.md | 46 +++ .../pages/workspace/doc/crdt_widget.rs | 271 ++++++++++++++++-- .../pages/workspace/doc/projection_layout.rs | 136 +++++++++ .../workspace/doc/projection_renderer.rs | 8 +- .../pages/workspace/doc/projection_session.rs | 14 + .../pages/workspace/doc/tests.rs | 205 +++++++++++++ 6 files changed, 648 insertions(+), 32 deletions(-) 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; +}