From ff4e560e7de22ca5916892a5c03ac104b4b7f3fc Mon Sep 17 00:00:00 2001 From: Arthur M Date: Wed, 14 Oct 2026 19:43:06 +0200 Subject: [PATCH] Ctrl+A stays on this page, and in a table grows cell, then row, then table, then the document (#1424) (#1478) * Ctrl+A stays on this page, and in a table grows cell, then row, then table, then the document (#1424) Fixes #1424 * Table: Option::map for the outer-cell selection (clippy::manual_map on Rust 1.99) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/caret.rs | 14 ++++++- crates/engine/src/cmd/table.rs | 34 +++++++++++++++ crates/engine/src/cmd/tools.rs | 3 +- crates/engine/src/session.rs | 6 +++ crates/engine/src/tests.rs | 76 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 7 +++- crates/ui-egui/src/chrome.rs | 6 ++- crates/ui-egui/src/lib.rs | 18 +++++++- 8 files changed, 158 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index 950e59917..50c1cd019 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -277,9 +277,21 @@ fn set(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -fn select_all(s: &mut Session, _: &Value) -> CmdResult { +fn select_all(s: &mut Session, v: &Value) -> CmdResult { + if s.selects_whole_story() { + s.column = None; + s.column_mode = false; + return sel_result(s); + } + if !p::bool(v, "whole").unwrap_or(false) + && let Some(r) = super::table::grow_select_all(s) + { + return r; + } let st = s.sel.focus.story; s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) }; + s.column = None; + s.column_mode = false; sel_result(s) } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e1e09f7fb..3c153d860 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -747,6 +747,40 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"result": text})) } +/// Ctrl+A in a table, as in Word: the cell, then its row, then the table. A nested table that +/// is already fully selected grows to the cell around it. Returns `None` when the next step is +/// the whole story (not in a table, or the top-level table is already selected). +pub(crate) fn grow_select_all(s: &mut Session) -> Option { + let story = s.sel.focus.story; + if let Some(cr) = s.cell_range().filter(|cr| cr.story == story).cloned() { + let tp = cr.table.clone(); + let (last_row, last_col, one_cell) = { + let t = s.doc.table(story, &tp)?; + let last_row = t.rows.len().saturating_sub(1); + let last_col = t.cols().saturating_sub(1); + let one_cell = cr.rows.0 == cr.rows.1 + && t.rows.get(cr.rows.0).is_some_and(|row| (0..row.cells.len()).any(|c| grid_cols(t, cr.rows.0, c) == cr.cols)); + (last_row, last_col, one_cell) + }; + if cr.rows == (0, last_row) && cr.cols == (0, last_col) { + return tp.cell().map(|(otp, or, oc)| select_cells(s, &otp, Some((or, Some(oc))))); + } + if cr.rows.0 == cr.rows.1 && cr.cols == (0, last_col) { + return Some(select_cells(s, &tp, None)); + } + if one_cell { + return Some(select_cells(s, &tp, Some((cr.rows.0, None)))); + } + return Some(select_cells(s, &tp, None)); + } + let (a, b) = s.sel.ordered(); + let (tp, r, c) = a.path.cell()?; + if b.path.cell() != Some((tp.clone(), r, c)) { + return None; + } + Some(select_cells(s, &tp, Some((r, Some(c))))) +} + fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option)>) -> CmdResult { let story = s.sel.focus.story; let Some(t) = s.doc.table(story, tp) else { return sel_result(s) }; diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 8dcceda07..669601433 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -647,7 +647,8 @@ pub fn specs() -> Vec { } else { "select.all" }; - s.run(id, &json!({})) + let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) }; + s.run(id, ¶ms) }) .key("F8") .pure(), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9b9d21d86..f15030e9e 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -691,6 +691,12 @@ impl Session { self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) } + pub fn selects_whole_story(&self) -> bool { + let st = self.sel.focus.story; + let (a, b) = self.sel.ordered(); + a == self.doc.start_of(st) && b == self.doc.end_of(st) + } + /// Document revision (bumped by every change). pub fn rev(&self) -> u64 { self.rev diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 476d2bc9c..5b6a81304 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1127,6 +1127,82 @@ fn tables_commands() { assert!(s.run("table.merge", &json!({})).is_err()); } +fn cells(s: &Session) -> Option<((usize, usize), (usize, usize))> { + s.cell_range().map(|x| (x.rows, x.cols)) +} + +#[test] +fn select_all_grows_cell_then_row_then_table_then_document() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Before"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "A1"})); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 0))); + + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 0), (0, 0))), "first Ctrl+A selects the cell"); + assert_eq!(s.selected_text(), "A1"); + + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 0), (0, 1))), "second Ctrl+A selects the row"); + + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 1), (0, 1))), "third Ctrl+A selects the table"); + + run(&mut s, "select.all", json!({})); + assert!(s.column.is_none()); + let (a, b) = s.sel.ordered(); + assert_eq!(a, s.doc.start_of(StoryRef::Body)); + assert_eq!(b, s.doc.end_of(StoryRef::Body)); + assert!(s.selected_text().starts_with("Before")); + + run(&mut s, "select.all", json!({})); + let (a, b) = s.sel.ordered(); + assert_eq!((a, b), (s.doc.start_of(StoryRef::Body), s.doc.end_of(StoryRef::Body))); +} + +#[test] +fn select_all_from_a_column_grows_to_the_table() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "table.selectColumn", json!({})); + assert_eq!(cells(&s), Some(((0, 1), (0, 0)))); + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 1), (0, 1))), "Ctrl+A on a column selects the table"); +} + +#[test] +fn select_all_keeps_the_whole_story_when_it_ends_in_a_table() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "A1"})); + s.doc.body.pop(); + run(&mut s, "select.all", json!({})); + run(&mut s, "select.all", json!({})); + run(&mut s, "select.all", json!({})); + run(&mut s, "select.all", json!({})); + let whole = (s.doc.start_of(StoryRef::Body), s.doc.end_of(StoryRef::Body)); + assert!(s.doc.end_of(StoryRef::Body).path.cell().is_some()); + assert_eq!(s.sel.ordered(), whole); + run(&mut s, "select.all", json!({})); + assert!(s.column.is_none()); + assert_eq!(s.sel.ordered(), whole); +} + +#[test] +fn select_extend_from_a_table_paragraph_selects_the_document() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "Hello world"})); + run(&mut s, "select.paragraph", json!({})); + run(&mut s, "select.extend", json!({})); + assert!(s.column.is_none()); + let (a, b) = s.sel.ordered(); + assert_eq!(a, s.doc.start_of(StoryRef::Body)); + assert_eq!(b, s.doc.end_of(StoryRef::Body)); +} + /// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a /// long sentence widens (wrapping within the page), and the widths are written to the grid. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1047c7ef2..9ec3c8856 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -799,7 +799,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let caret = layout.caret_on(&app.session.sel_ends().focus, app.session.page_hint); // Editing a header/footer (or a note) dims the body; once per frame, for every page. let dim_body = dims_body(app, &layout); - if let Some(c) = caret { + if let Some(c) = caret + && !app.session.selects_whole_story() + { app.session.page_hint = c.page; } let mut scroll_target: Option = None; @@ -1941,7 +1943,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo "paragraph" => "select.paragraph", _ => "select.line", }; - let _ = app.run(id, json!({})); + let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) }; + let _ = app.run(id, params); let (start, end) = app.session.sel.ordered(); (unit, start, end) }; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7e7483a9e..187fa7d54 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -367,7 +367,11 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 0.0); let l = app.session.layout(); - let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1); + let page = if app.session.selects_whole_story() { + app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1 + } else { + l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1) + }; let st = |ui: &mut Ui, s: &str| { let font = TypeRung::Caption.regular(); let track = TypeRung::Caption.tracking(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 687a0b479..cddfaebbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -944,7 +944,9 @@ impl WordApp { self.canvas.read_first_visible = None; } // Drawing leaves the caret where it was: following it would pull the view off the ink. - if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") { + if self.session.selects_whole_story() || id == "select.all" { + self.canvas.scroll_to_caret = false; + } else if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") { self.canvas.scroll_to_caret = true; } for req in std::mem::take(&mut self.session.ui_requests) { @@ -4997,6 +4999,20 @@ mod tests { assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view"); } + #[test] + fn select_all_does_not_scroll_to_the_end() { + let mut a = typed(); + a.canvas.scroll_to_caret = true; + a.run("select.all", json!({})).unwrap(); + assert!(!a.canvas.scroll_to_caret); + a.run("format.bold", json!({})).unwrap(); + assert!(!a.canvas.scroll_to_caret); + a.run("edit.copy", json!({})).unwrap(); + assert!(!a.canvas.scroll_to_caret); + a.run("text.insert", json!({"text": "x"})).unwrap(); + assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view"); + } + /// #1129: with no document open (the Start screen's empty window), `document.inspect` and /// `ui.render` on the control channel say so instead of reading the hidden placeholder. #[test]