diff --git a/crates/apps/doc/doc-engine/README.md b/crates/apps/doc/doc-engine/README.md index 70b5138..c20bc50 100644 --- a/crates/apps/doc/doc-engine/README.md +++ b/crates/apps/doc/doc-engine/README.md @@ -31,11 +31,3 @@ 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 8321240..df37f6b 100644 --- a/crates/apps/doc/doc-engine/src/crdt/document.rs +++ b/crates/apps/doc/doc-engine/src/crdt/document.rs @@ -175,39 +175,15 @@ 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, 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::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::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 1aececa..96e23e0 100644 --- a/crates/apps/doc/doc-engine/tests/materialize.rs +++ b/crates/apps/doc/doc-engine/tests/materialize.rs @@ -1,4 +1,3 @@ -use doc_engine::controller::DocumentController; use doc_engine::crdt::{CrdtDocument, OpId, Operation}; #[test] fn text_materializes_into_its_block() { @@ -504,27 +503,3 @@ 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 df8ac62..5bb60ad 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,7 +509,6 @@ 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 @@ -743,48 +742,3 @@ 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 6a4248e..a3086f5 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,13 +5,11 @@ 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, 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, + block_glyph_offset, glyph_index_of, layout_projection, parse_op_id, step_glyph, + selection_handles as compute_selection_handles, 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, TableCellCursor}; +use crate::construction_frame::pages::workspace::doc::projection_session::{crdt_engine_from_saved, crdt_save_wire, ProjectionSession}; use crate::construction_frame::pages::workspace::doc::{ MobileGestureAction, MobileGestureRouter, MobileGestureState, }; @@ -230,26 +228,8 @@ 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()) { @@ -286,25 +266,7 @@ impl CrdtDocEditor { if projected.kind == "table" { return; } let offset = self.cursor_offset_in(&block, &projected); if offset >= projected.text_atoms.len() { - // 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; - } - } + // Block end: fold the next block into this one. if self.engine.merge_block_after("local", block) { self.sanitize_cursor(); self.redraw(cx); @@ -365,18 +327,12 @@ impl CrdtDocEditor { if ke.modifiers.shift { self.redo(cx); } else { self.undo(cx); } 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; } + 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; } _ => {} } } - 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), @@ -387,115 +343,6 @@ 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); @@ -520,15 +367,6 @@ 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 { @@ -565,7 +403,7 @@ impl Widget for CrdtDocEditor { if let Event::TouchUpdate(tu) = event { self.saw_touch = true; if let Some(touch) = tu.touches.first() { - let point = self.layout_point(cx, touch.abs); + let point = touch.abs; match touch.state { TouchState::Start => { let layout = layout_projection(&self.engine.projection); @@ -605,18 +443,6 @@ 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); @@ -631,62 +457,36 @@ 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); - let point = self.layout_point(cx, fe.abs); - if let Some(glyph) = layout.hit_test(point) { + if let Some(glyph) = layout.hit_test(fe.abs) { 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); - 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); } + if let Some(glyph) = layout.hit_test(fe.abs) { self.session.selection_focus = Some(glyph.atom.clone()); self.redraw(cx); } } } Hit::TextInput(TextInputEvent { ref input, .. }) if !input.is_empty() => { - 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); + // 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); } - } 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); - } - } } } _ => {} @@ -702,16 +502,11 @@ 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, Rect { pos: rect.pos + glyph.rect.pos, size: glyph.rect.size }); - } + if selected { self.draw_selection.draw_abs(cx, glyph.rect); } } } // Long-press selections expose draggable start/end handles. The @@ -720,25 +515,15 @@ 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, 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 }); + self.draw_selection_handle.draw_abs(cx, handles.start_rect); + self.draw_selection_handle.draw_abs(cx, handles.end_rect); } } 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: 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; } + self.draw_caret.draw_abs(cx, Rect { pos: glyph.rect.pos, size: dvec2(2.0, glyph.rect.size.y) }); } } 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 b907341..15c68c1 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,139 +495,3 @@ 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 7eaf6d7..3829583 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, TABLE_CELL_TEXT_INSET, TEXT_CHAR_ADVANCE, + ProjectionLayoutTree, LAYOUT_MARGIN, }; 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 * TEXT_CHAR_ADVANCE; + draw.draw_abs(cx, dvec2(x, y), &run.text); + x += run.text.chars().count() as f64 * 7.0; } } } @@ -138,7 +138,7 @@ impl ProjectionRenderer { text.draw_abs( cx, dvec2( - rect.pos.x + TABLE_CELL_TEXT_INSET, + rect.pos.x + 6.0, 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 ba34d36..4cd77dc 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,25 +1,11 @@ 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 df6d4d1..f3508bb 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,208 +644,3 @@ 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; -}