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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Arthur M
2026-10-14 13:43:06 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 1be06ca323
commit ff4e560e7d
8 changed files with 158 additions and 6 deletions
+13 -1
View File
@@ -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)
}
+34
View File
@@ -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<CmdResult> {
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<usize>)>) -> CmdResult {
let story = s.sel.focus.story;
let Some(t) = s.doc.table(story, tp) else { return sel_result(s) };
+2 -1
View File
@@ -647,7 +647,8 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
"select.all"
};
s.run(id, &json!({}))
let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) };
s.run(id, &params)
})
.key("F8")
.pure(),
+6
View File
@@ -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
+76
View File
@@ -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]
+5 -2
View File
@@ -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<Rect> = 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)
};
+5 -1
View File
@@ -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();
+17 -1
View File
@@ -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]