Compare commits
2 commits
729163dfe1
...
478c7b33b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478c7b33b0 | ||
|
|
9a167f4df3 |
9 changed files with 707 additions and 34 deletions
|
|
@ -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
|
in `order`, so peers replaying the same op log converge on the same
|
||||||
visible sequence without a new `InsertBlock` op. Undo/redo uses the
|
visible sequence without a new `InsertBlock` op. Undo/redo uses the
|
||||||
symmetric `Compensation::{SplitBlock, MergeBlocks}` pair.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -175,15 +175,39 @@ impl CrdtDocument {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut row_items: BTreeMap<String, BTreeMap<Option<String>, Vec<OpId>>> = BTreeMap::new();
|
||||||
|
let mut column_items: BTreeMap<String, BTreeMap<Option<String>, Vec<OpId>>> = BTreeMap::new();
|
||||||
for op in self.operations.values() {
|
for op in self.operations.values() {
|
||||||
match op {
|
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::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, .. } if !deleted_columns.contains(id) => tables.entry(format!("{}:{}", table.actor, table.counter)).or_default().columns.push(format!("{}:{}", id.actor, id.counter)),
|
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::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) }); }
|
Operation::MergeTableCells { id, table, start_row, start_column, end_row, end_column } if !split_merges.contains(id) => { let entry=tables.entry(format!("{}:{}",table.actor,table.counter)).or_default(); entry.merges.push(ProjectedTableMerge { id:format!("{}:{}",id.actor,id.counter), start_row:format!("{}:{}",start_row.actor,start_row.counter), start_column:format!("{}:{}",start_column.actor,start_column.counter), end_row:format!("{}:{}",end_row.actor,end_row.counter), end_column:format!("{}:{}",end_column.actor,end_column.counter) }); }
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rows and columns order by their `after` anchor chain, not by op
|
||||||
|
// id: inserting below an existing row stays below it even when a
|
||||||
|
// peer (or the local user) had appended later rows in the mean time.
|
||||||
|
// Siblings sharing one anchor serialize RGA-style — counter
|
||||||
|
// DESCENDING, then actor ASCENDING — exactly like text atoms, so a
|
||||||
|
// newer "insert below X" renders before older rows anchored on X.
|
||||||
|
fn visit_axis(after: Option<String>, items: &BTreeMap<Option<String>, Vec<OpId>>, out: &mut Vec<String>) {
|
||||||
|
if let Some(ids) = items.get(&after) {
|
||||||
|
let mut ids = ids.clone();
|
||||||
|
ids.sort_by(|a, b| b.counter.cmp(&a.counter).then_with(|| a.actor.cmp(&b.actor)));
|
||||||
|
for id in ids {
|
||||||
|
out.push(format!("{}:{}", id.actor, id.counter));
|
||||||
|
visit_axis(Some(format!("{}:{}", id.actor, id.counter)), items, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (table_id, items) in &row_items {
|
||||||
|
if let Some(table) = tables.get_mut(table_id) { visit_axis(None, items, &mut table.rows); }
|
||||||
|
}
|
||||||
|
for (table_id, items) in &column_items {
|
||||||
|
if let Some(table) = tables.get_mut(table_id) { visit_axis(None, items, &mut table.columns); }
|
||||||
|
}
|
||||||
for (id, table) in &mut tables { table.id = id.clone(); }
|
for (id, table) in &mut tables { table.id = id.clone(); }
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
for op in self.operations.values() {
|
for op in self.operations.values() {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use doc_engine::controller::DocumentController;
|
||||||
use doc_engine::crdt::{CrdtDocument, OpId, Operation};
|
use doc_engine::crdt::{CrdtDocument, OpId, Operation};
|
||||||
#[test]
|
#[test]
|
||||||
fn text_materializes_into_its_block() {
|
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.len(),1);
|
||||||
assert_eq!(c.projection.blocks[0].text,"helloworld");
|
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)]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,7 @@ collaboration-safe acknowledged frontier.
|
||||||
- [x] CRDT-native table rendering
|
- [x] CRDT-native table rendering
|
||||||
- [x] CRDT-native advanced node rendering
|
- [x] CRDT-native advanced node rendering
|
||||||
- [x] CRDT-native keyboard editing
|
- [x] CRDT-native keyboard editing
|
||||||
|
- [x] CRDT-native in-cell table editing
|
||||||
- [x] Switch workspace DSL to CrdtDocEditor
|
- [x] Switch workspace DSL to CrdtDocEditor
|
||||||
|
|
||||||
- [x] CrdtDocEditor engine installation API
|
- [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
|
remains covered by the projection layout/session unit tests until a
|
||||||
Studio runner exists. The keyboard, touch, and frame-clock dispatch is
|
Studio runner exists. The keyboard, touch, and frame-clock dispatch is
|
||||||
direct (no area hit gate) and fully covered by the runtime tests.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ use makepad_widgets::makepad_platform::event::TouchState;
|
||||||
// in a `#[rust]` field and reject it with "Unexpected field form".
|
// in a `#[rust]` field and reject it with "Unexpected field form".
|
||||||
use doc_engine::controller::DocumentController;
|
use doc_engine::controller::DocumentController;
|
||||||
use crate::construction_frame::pages::workspace::doc::projection_layout::{
|
use crate::construction_frame::pages::workspace::doc::projection_layout::{
|
||||||
block_glyph_offset, glyph_index_of, layout_projection, parse_op_id, step_glyph,
|
block_glyph_offset, cell_text_backspace, cell_text_delete, cell_text_insert, glyph_index_of,
|
||||||
selection_handles as compute_selection_handles, word_atom_range, SelectionHandles,
|
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_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::{
|
use crate::construction_frame::pages::workspace::doc::{
|
||||||
MobileGestureAction, MobileGestureRouter, MobileGestureState,
|
MobileGestureAction, MobileGestureRouter, MobileGestureState,
|
||||||
};
|
};
|
||||||
|
|
@ -228,8 +230,26 @@ impl CrdtDocEditor {
|
||||||
let index = self.engine.projection.blocks.iter().position(|candidate| candidate.id == id).unwrap_or(0);
|
let index = self.engine.projection.blocks.iter().position(|candidate| candidate.id == id).unwrap_or(0);
|
||||||
if index == 0 { return; }
|
if index == 0 { return; }
|
||||||
let previous = &self.engine.projection.blocks[index - 1];
|
let previous = &self.engine.projection.blocks[index - 1];
|
||||||
if previous.kind == "table" { return; }
|
|
||||||
let previous_id_string = previous.id.clone();
|
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 seam = previous.text.chars().count();
|
||||||
let Some(previous_id) = parse_op_id(&previous_id_string) else { return; };
|
let Some(previous_id) = parse_op_id(&previous_id_string) else { return; };
|
||||||
if self.engine.merge_block_after("local", previous_id.clone()) {
|
if self.engine.merge_block_after("local", previous_id.clone()) {
|
||||||
|
|
@ -266,7 +286,25 @@ impl CrdtDocEditor {
|
||||||
if projected.kind == "table" { return; }
|
if projected.kind == "table" { return; }
|
||||||
let offset = self.cursor_offset_in(&block, &projected);
|
let offset = self.cursor_offset_in(&block, &projected);
|
||||||
if offset >= projected.text_atoms.len() {
|
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) {
|
if self.engine.merge_block_after("local", block) {
|
||||||
self.sanitize_cursor();
|
self.sanitize_cursor();
|
||||||
self.redraw(cx);
|
self.redraw(cx);
|
||||||
|
|
@ -327,12 +365,18 @@ impl CrdtDocEditor {
|
||||||
if ke.modifiers.shift { self.redo(cx); } else { self.undo(cx); }
|
if ke.modifiers.shift { self.redo(cx); } else { self.undo(cx); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
KeyCode::KeyB => { self.toggle_selection_style(cx, "bold"); return; }
|
// Cell text has no style runs yet; style toggles stay
|
||||||
KeyCode::KeyI => { self.toggle_selection_style(cx, "italic"); return; }
|
// text-selection-only while the caret lives in a table.
|
||||||
KeyCode::KeyU => { self.toggle_selection_style(cx, "underline"); return; }
|
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 {
|
match ke.key_code {
|
||||||
KeyCode::ArrowLeft => self.move_selection_edge(cx, false, ke.modifiers.shift),
|
KeyCode::ArrowLeft => self.move_selection_edge(cx, false, ke.modifiers.shift),
|
||||||
KeyCode::ArrowRight => self.move_selection_edge(cx, true, 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) {
|
fn move_selection_edge(&mut self, cx: &mut Cx, forward: bool, shift: bool) {
|
||||||
let Some(current) = self.session.cursor_atom.clone() else {
|
let Some(current) = self.session.cursor_atom.clone() else {
|
||||||
let layout = layout_projection(&self.engine.projection);
|
let layout = layout_projection(&self.engine.projection);
|
||||||
|
|
@ -367,6 +520,15 @@ impl CrdtDocEditor {
|
||||||
self.session.cursor_atom = Some(glyph.atom.clone());
|
self.session.cursor_atom = Some(glyph.atom.clone());
|
||||||
self.redraw(cx);
|
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 {
|
impl Widget for CrdtDocEditor {
|
||||||
|
|
@ -403,7 +565,7 @@ impl Widget for CrdtDocEditor {
|
||||||
if let Event::TouchUpdate(tu) = event {
|
if let Event::TouchUpdate(tu) = event {
|
||||||
self.saw_touch = true;
|
self.saw_touch = true;
|
||||||
if let Some(touch) = tu.touches.first() {
|
if let Some(touch) = tu.touches.first() {
|
||||||
let point = touch.abs;
|
let point = self.layout_point(cx, touch.abs);
|
||||||
match touch.state {
|
match touch.state {
|
||||||
TouchState::Start => {
|
TouchState::Start => {
|
||||||
let layout = layout_projection(&self.engine.projection);
|
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.cursor_atom = Some(glyph.atom.clone());
|
||||||
self.session.selection_anchor = None;
|
self.session.selection_anchor = None;
|
||||||
self.session.selection_focus = 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);
|
self.redraw(cx);
|
||||||
|
|
@ -457,21 +631,46 @@ impl Widget for CrdtDocEditor {
|
||||||
Hit::FingerDown(fe) if fe.is_primary_hit() && !self.saw_touch => {
|
Hit::FingerDown(fe) if fe.is_primary_hit() && !self.saw_touch => {
|
||||||
cx.set_key_focus(self.draw_bg.area());
|
cx.set_key_focus(self.draw_bg.area());
|
||||||
let layout = layout_projection(&self.engine.projection);
|
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()); }
|
if !fe.modifiers.shift { self.session.selection_anchor = Some(glyph.atom.clone()); }
|
||||||
self.session.cursor_block = Some(glyph.block.clone());
|
self.session.cursor_block = Some(glyph.block.clone());
|
||||||
self.session.cursor_atom = Some(glyph.atom.clone());
|
self.session.cursor_atom = Some(glyph.atom.clone());
|
||||||
self.session.selection_focus = 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);
|
cx.show_text_ime(self.draw_bg.area(), fe.abs);
|
||||||
}
|
}
|
||||||
Hit::FingerMove(fe) if !self.saw_touch => {
|
Hit::FingerMove(fe) if !self.saw_touch => {
|
||||||
if self.session.selection_anchor.is_some() {
|
if self.session.selection_anchor.is_some() {
|
||||||
let layout = layout_projection(&self.engine.projection);
|
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() => {
|
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);
|
||||||
|
self.redraw(cx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Typing over a selection replaces it; the insertion then
|
// Typing over a selection replaces it; the insertion then
|
||||||
// lands in the caret block instead of always the first.
|
// lands in the caret block instead of always the first.
|
||||||
self.delete_selection(cx);
|
self.delete_selection(cx);
|
||||||
|
|
@ -489,6 +688,7 @@ impl Widget for CrdtDocEditor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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_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_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));
|
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()) {
|
if let (Some(anchor), Some(focus)) = (self.session.selection_anchor.as_ref(), self.session.selection_focus.as_ref()) {
|
||||||
let mut selected = false;
|
let mut selected = false;
|
||||||
for glyph in &layout.glyphs {
|
for glyph in &layout.glyphs {
|
||||||
if &glyph.atom == anchor || &glyph.atom == focus { selected = !selected; }
|
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
|
// Long-press selections expose draggable start/end handles. The
|
||||||
|
|
@ -515,15 +720,25 @@ impl Widget for CrdtDocEditor {
|
||||||
if let (Some(anchor), Some(focus)) = selection {
|
if let (Some(anchor), Some(focus)) = selection {
|
||||||
self.selection_handles = compute_selection_handles(&layout, &anchor, &focus);
|
self.selection_handles = compute_selection_handles(&layout, &anchor, &focus);
|
||||||
if let Some(handles) = &self.selection_handles {
|
if let Some(handles) = &self.selection_handles {
|
||||||
self.draw_selection_handle.draw_abs(cx, handles.start_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, handles.end_rect);
|
self.draw_selection_handle.draw_abs(cx, Rect { pos: rect.pos + handles.end_rect.pos, size: handles.end_rect.size });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.selection_handles = None;
|
self.selection_handles = None;
|
||||||
}
|
}
|
||||||
if let Some(atom) = self.session.cursor_atom.as_ref() {
|
if let Some(atom) = self.session.cursor_atom.as_ref() {
|
||||||
if let Some(glyph) = layout.glyphs.iter().find(|glyph| &glyph.atom == atom) {
|
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()
|
DrawStep::done()
|
||||||
|
|
|
||||||
|
|
@ -495,3 +495,139 @@ pub fn projected_stats(projection: &DocumentProjection) -> (usize, usize) {
|
||||||
}
|
}
|
||||||
(words, chars)
|
(words, chars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Horizontal advance per character, shared with the projection renderer's
|
||||||
|
/// text drawing so caret math and glyphs agree.
|
||||||
|
pub const TEXT_CHAR_ADVANCE: f64 = 7.0;
|
||||||
|
/// Left inset of text inside a table cell (renderer draws at +6.0).
|
||||||
|
pub const TABLE_CELL_TEXT_INSET: f64 = 6.0;
|
||||||
|
|
||||||
|
use crate::construction_frame::pages::workspace::doc::projection_session::TableCellCursor;
|
||||||
|
|
||||||
|
/// Inserts `input` at character `offset` in `text`, returning the new cell
|
||||||
|
/// text and caret offset. Char-boundary safe (offsets are Unicode scalars,
|
||||||
|
/// matching the engine's offset convention).
|
||||||
|
pub fn cell_text_insert(text: &str, offset: usize, input: &str) -> (String, usize) {
|
||||||
|
let offset = offset.min(text.chars().count());
|
||||||
|
let byte = text.char_indices().nth(offset).map(|(index, _)| index).unwrap_or(text.len());
|
||||||
|
let mut out = String::with_capacity(text.len() + input.len());
|
||||||
|
out.push_str(&text[..byte]);
|
||||||
|
out.push_str(input);
|
||||||
|
out.push_str(&text[byte..]);
|
||||||
|
(out, offset + input.chars().count())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the character before `offset` (Backspace inside a cell);
|
||||||
|
/// `None` at the cell start or past the end, so callers can keep behavior
|
||||||
|
/// clamped instead of leaking into sibling cells.
|
||||||
|
pub fn cell_text_backspace(text: &str, offset: usize) -> Option<(String, usize)> {
|
||||||
|
if offset == 0 { return None; }
|
||||||
|
let mut chars: Vec<char> = text.chars().collect();
|
||||||
|
if offset > chars.len() { return None; }
|
||||||
|
chars.remove(offset - 1);
|
||||||
|
Some((chars.into_iter().collect(), offset - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the character at `offset` (forward Delete inside a cell);
|
||||||
|
/// `None` at the cell end.
|
||||||
|
pub fn cell_text_delete(text: &str, offset: usize) -> Option<(String, usize)> {
|
||||||
|
if offset >= text.chars().count() { return None; }
|
||||||
|
let mut chars: Vec<char> = text.chars().collect();
|
||||||
|
chars.remove(offset);
|
||||||
|
Some((chars.into_iter().collect(), offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reading-order neighbor of `(row, col)` inside one table, wrapping
|
||||||
|
/// across rows; `None` at the table edges so callers can exit to the
|
||||||
|
/// surrounding text blocks.
|
||||||
|
pub fn neighbor_cell(rows: usize, cols: usize, row: usize, col: usize, forward: bool) -> Option<(usize, usize)> {
|
||||||
|
if rows == 0 || cols == 0 { return None; }
|
||||||
|
let index = row * cols + col;
|
||||||
|
let next = if forward { index.checked_add(1) } else { index.checked_sub(1) }?;
|
||||||
|
(next < rows * cols).then(|| (next / cols, next % cols))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a cell cursor to `(table_index, row, col)` layout indices,
|
||||||
|
/// validating every id against the live projection. Stale cursors (e.g.
|
||||||
|
/// after an undo removed the table) return `None` so callers can clear.
|
||||||
|
pub fn table_cell_position(
|
||||||
|
layout: &ProjectionLayoutTree,
|
||||||
|
projection: &DocumentProjection,
|
||||||
|
cursor: &TableCellCursor,
|
||||||
|
) -> Option<(usize, usize, usize)> {
|
||||||
|
let table_id = format!("{}:{}", cursor.table.actor, cursor.table.counter);
|
||||||
|
let table_index = layout.tables.iter().position(|table| table.block_id == table_id)?;
|
||||||
|
let projected = projection.tables.get(&table_id)?;
|
||||||
|
let row_key = format!("{}:{}", cursor.row.actor, cursor.row.counter);
|
||||||
|
let column_key = format!("{}:{}", cursor.column.actor, cursor.column.counter);
|
||||||
|
let row = projected.rows.iter().position(|row| row == &row_key)?;
|
||||||
|
let col = projected.columns.iter().position(|col| col == &column_key)?;
|
||||||
|
Some((table_index, row, col))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current text of the cursor's cell from the projection (raw cell text;
|
||||||
|
/// unlike the layout, merge-covered cells keep their stored value).
|
||||||
|
pub fn table_cell_text(projection: &DocumentProjection, cursor: &TableCellCursor) -> String {
|
||||||
|
let table_id = format!("{}:{}", cursor.table.actor, cursor.table.counter);
|
||||||
|
let key = (
|
||||||
|
format!("{}:{}", cursor.row.actor, cursor.row.counter),
|
||||||
|
format!("{}:{}", cursor.column.actor, cursor.column.counter),
|
||||||
|
);
|
||||||
|
projection.tables.get(&table_id).and_then(|table| table.cells.get(&key)).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a cell cursor from layout indices — the tap-to-cell entry point
|
||||||
|
/// and the arrow-key cell hop. `offset` is clamped to the cell text.
|
||||||
|
pub fn table_cell_cursor_at(
|
||||||
|
layout: &ProjectionLayoutTree,
|
||||||
|
projection: &DocumentProjection,
|
||||||
|
table_index: usize,
|
||||||
|
row: usize,
|
||||||
|
col: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Option<TableCellCursor> {
|
||||||
|
let table = layout.tables.get(table_index)?;
|
||||||
|
let projected = projection.tables.get(&table.block_id)?;
|
||||||
|
let row_id = projected.rows.get(row)?;
|
||||||
|
let column_id = projected.columns.get(col)?;
|
||||||
|
let text_len = layout.tables.get(table_index)?.cell(row, col).map(|cell| cell.text.chars().count()).unwrap_or(0);
|
||||||
|
Some(TableCellCursor {
|
||||||
|
table: parse_op_id(&table.block_id)?,
|
||||||
|
row: parse_op_id(row_id)?,
|
||||||
|
column: parse_op_id(column_id)?,
|
||||||
|
offset: offset.min(text_len),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First text-block id after (`forward`) or before the table in the
|
||||||
|
/// projection's unified order; `None` at document edges. Advanced nodes in
|
||||||
|
/// the order are skipped, so the caret always lands on editable text.
|
||||||
|
pub fn neighbor_text_block(projection: &DocumentProjection, table_id: &str, forward: bool) -> Option<OpId> {
|
||||||
|
let index = projection.order.iter().position(|id| id == table_id)?;
|
||||||
|
let is_text = |id: &String| {
|
||||||
|
projection.blocks.iter().find(|block| &block.id == id).filter(|block| block.kind != "table")
|
||||||
|
};
|
||||||
|
let candidate = if forward {
|
||||||
|
projection.order[index + 1..].iter().find_map(is_text)
|
||||||
|
} else {
|
||||||
|
projection.order[..index].iter().rev().find_map(is_text)
|
||||||
|
}?;
|
||||||
|
parse_op_id(&candidate.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout-space 2px caret rect for a cell cursor, using the renderer's
|
||||||
|
/// text inset and advance so it lands between the drawn characters.
|
||||||
|
pub fn table_cell_caret(
|
||||||
|
layout: &ProjectionLayoutTree,
|
||||||
|
projection: &DocumentProjection,
|
||||||
|
cursor: &TableCellCursor,
|
||||||
|
) -> Option<Rect> {
|
||||||
|
let (table_index, row, col) = table_cell_position(layout, projection, cursor)?;
|
||||||
|
let cell = layout.tables.get(table_index)?.cell(row, col)?;
|
||||||
|
let text_len = cell.text.chars().count();
|
||||||
|
let x = cell.rect.pos.x + TABLE_CELL_TEXT_INSET + cursor.offset.min(text_len) as f64 * TEXT_CHAR_ADVANCE;
|
||||||
|
Some(Rect {
|
||||||
|
pos: dvec2(x, cell.rect.pos.y + (cell.rect.size.y - 18.0).max(0.0) * 0.5 + 1.0),
|
||||||
|
size: dvec2(2.0, 16.0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use doc_engine::projection::DocumentProjection;
|
use doc_engine::projection::DocumentProjection;
|
||||||
use makepad_widgets::*;
|
use makepad_widgets::*;
|
||||||
use crate::construction_frame::pages::workspace::doc::projection_layout::{
|
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;
|
pub struct ProjectionRenderer;
|
||||||
|
|
@ -54,7 +54,7 @@ impl ProjectionRenderer {
|
||||||
&mut *regular
|
&mut *regular
|
||||||
};
|
};
|
||||||
draw.draw_abs(cx, dvec2(x, y), &run.text);
|
draw.draw_abs(cx, dvec2(x, y), &run.text);
|
||||||
x += run.text.chars().count() as f64 * 7.0;
|
x += run.text.chars().count() as f64 * TEXT_CHAR_ADVANCE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ impl ProjectionRenderer {
|
||||||
text.draw_abs(
|
text.draw_abs(
|
||||||
cx,
|
cx,
|
||||||
dvec2(
|
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,
|
rect.pos.y + (rect.size.y - 18.0).max(0.0) * 0.5,
|
||||||
),
|
),
|
||||||
&cell.text,
|
&cell.text,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,25 @@
|
||||||
use doc_engine::crdt::OpId;
|
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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct ProjectionSession {
|
pub struct ProjectionSession {
|
||||||
pub cursor_block: Option<OpId>,
|
pub cursor_block: Option<OpId>,
|
||||||
pub cursor_atom: Option<OpId>,
|
pub cursor_atom: Option<OpId>,
|
||||||
pub selection_anchor: Option<OpId>,
|
pub selection_anchor: Option<OpId>,
|
||||||
pub selection_focus: Option<OpId>,
|
pub selection_focus: Option<OpId>,
|
||||||
|
/// Present while the caret lives in a table cell; the text-block
|
||||||
|
/// cursor fields are cleared when this is set and vice versa.
|
||||||
|
pub cell_cursor: Option<TableCellCursor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save wire shared with the legacy editor: a `#MP_CRDT_V1` header line
|
/// Save wire shared with the legacy editor: a `#MP_CRDT_V1` header line
|
||||||
|
|
|
||||||
|
|
@ -644,3 +644,208 @@ fn runtime_backspace_on_empty_split_tail_merges_with_previous() {
|
||||||
assert_eq!(block_texts(&editor), ["abcd"]);
|
assert_eq!(block_texts(&editor), ["abcd"]);
|
||||||
assert!(editor.session.cursor_atom.is_some(), "caret re-anchored on a live glyph after merge");
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue