From 752477a2315c09f65ae5322521c507501b42c849 Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:13:44 +0000 Subject: [PATCH 1/3] feat(ui): Ctrl+[ / Ctrl+] select precedent and dependent cells (#1865) Adds formulas.selectPrecedents / formulas.selectDependents engine commands and binds them to Ctrl+[ and Ctrl+] (Ctrl+Shift walks every level), matching Excel's shortcut parity. Direct-level traces keep whole precedent ranges; cross-sheet precedents jump to the owning sheet; an empty trace leaves the selection untouched with an actionable message. --- =560 | 0 crates/engine/src/cmd/formulas.rs | 71 ++++++++++++++++ crates/ui-egui/src/keymap.rs | 5 ++ crates/ui-egui/tests/trace_navigation.rs | 100 +++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 =560 create mode 100644 crates/ui-egui/tests/trace_navigation.rs diff --git a/=560 b/=560 new file mode 100644 index 000000000..e69de29bb diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 40df72df0..0607901ff 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -49,6 +49,8 @@ pub fn specs() -> Vec { ), cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents), cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), + cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents), + cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents), cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()), cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), @@ -479,6 +481,75 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { Ok(Json::Array(v)) } +/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on +/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]). +fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result { + use std::collections::BTreeSet; + const CAP: usize = 4096; + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let sheet = d.wb.active_sheet; + let all = bool_param(p, "all").unwrap_or(false); + // (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells. + let mut found: BTreeSet<(usize, String)> = BTreeSet::new(); + let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new(); + let mut frontier = vec![(sheet, at)]; + seen.insert((sheet, at)); + loop { + let Some((ks, kc)) = frontier.pop() else { break }; + if precedents { + for a in d.calc.graph.precedents_of((ks, kc)) { + if all { + for c in a.range.iter().take(CAP) { + if seen.insert((a.sheet, c)) { + found.insert((a.sheet, c.a1())); + frontier.push((a.sheet, c)); + } + } + } else { + found.insert((a.sheet, a.range.a1())); + } + } + } else { + let mut deps = Vec::new(); + d.calc.graph.dependents(ks, kc, &mut deps); + for k2 in deps { + if all { + if seen.insert(k2) { + found.insert((k2.0, k2.1.a1())); + frontier.push(k2); + } + } else { + found.insert((k2.0, k2.1.a1())); + } + } + } + if !all { + break; + } + } + found.remove(&(sheet, at.a1())); + let name = if precedents { "precedent" } else { "dependent" }; + let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else { + return Err(bad("cell", format!("No {name} cells."))); + }; + let list: Vec = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect(); + let mut params = json!({ "range": list.join(",") }); + if si != sheet { + params["sheet"] = json!(si); + } + drop(d); + s.execute("selection.set", params) +} + +fn select_precedents(s: &mut Session, p: &Json) -> Result { + select_trace_cells(s, p, true) +} + +fn select_dependents(s: &mut Session, p: &Json) -> Result { + select_trace_cells(s, p, false) +} + fn show_formulas(s: &mut Session, p: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet; let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 032db1a2d..a6497db8b 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -84,6 +84,11 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Semicolon if ctrl && s => Shortcut::Time, Quote if ctrl && s => Shortcut::ValueAbove, Quote if ctrl => Shortcut::FormulaAbove, + // Ctrl+[ / Ctrl+] (Excel): select precedent / dependent cells; Shift walks every level. + OpenBracket if ctrl && !s => run("formulas.selectPrecedents"), + OpenBracket if ctrl => Shortcut::Run("formulas.selectPrecedents", json!({"all": true})), + CloseBracket if ctrl && !s => run("formulas.selectDependents"), + CloseBracket if ctrl => Shortcut::Run("formulas.selectDependents", json!({"all": true})), // Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;. Semicolon if mac && ctrl => Shortcut::Date, Semicolon if mac && cmd && !s => Shortcut::Time, diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/trace_navigation.rs new file mode 100644 index 000000000..f891a01c8 --- /dev/null +++ b/crates/ui-egui/tests/trace_navigation.rs @@ -0,0 +1,100 @@ +//! Ctrl+[ / Ctrl+] select precedent and dependent cells (#1865). + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn active(h: &Harness) -> String { + let d = h.state().session.active().unwrap(); + d.selection.active.a1() +} + +fn ranges(h: &Harness) -> Vec { + let d = h.state().session.active().unwrap(); + d.selection.ranges.iter().map(|r| r.a1()).collect() +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +/// A1 and B1 feed C1; C1 feeds D1. +fn chain() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A1+B1"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "=C1*2"})).unwrap(); + s +} + +#[test] +fn ctrl_bracket_selects_precedents_and_dependents() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "C1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + let mut r = ranges(&h); + r.sort(); + assert_eq!(r, vec!["A1".to_string(), "B1".to_string()], "Ctrl+[ selects the precedent cells"); + h.state_mut().session.execute("selection.set", json!({"range": "C1"})).unwrap(); + key(&mut h, Key::CloseBracket, Modifiers::CTRL); + assert_eq!(active(&h), "D1", "Ctrl+] lands on the dependent cell"); +} + +#[test] +fn ctrl_shift_bracket_walks_every_level() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "D1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL | Modifiers::SHIFT); + let r = ranges(&h); + assert!( + r.contains(&"A1".to_string()) && r.contains(&"B1".to_string()) && r.contains(&"C1".to_string()), + "Ctrl+Shift+[ walks back through C1 to A1:B1, got {r:?}" + ); +} + +#[test] +fn no_precedents_leaves_selection_untouched() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "A1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + assert_eq!(active(&h), "A1", "a plain entry cell has no precedents to select"); +} + +#[test] +fn cross_sheet_precedents_jump_to_the_other_sheet() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Src"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("cell.set", json!({"cell": "Src!B2", "input": "42"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=Src!B2*2"})).unwrap(); + // Back on the first sheet, Ctrl+[ from A1 jumps to Src!B2. + let d = s.active().unwrap(); + let first = d.wb.active_sheet; + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut h = harness(s); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + let d = h.state().session.active().unwrap(); + assert_ne!(d.wb.active_sheet, first, "selection jumped to the precedent's sheet"); + assert_eq!(d.selection.active, CellRef { row: 1, col: 1 }, "B2 on the Src sheet"); +} From d19627fc269ec982badaebb1493fb9bfe4a4bf3e Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:54:45 +0000 Subject: [PATCH 2/3] fix(engine): walk stored cells in transitive precedent selection Bound the all-level walk to the sheet sparse cell store instead of iterating every position of each precedent area: formulas late in a wide area are no longer skipped, blank positions are never selected, and only formula cells extend the frontier. Adds a regression test (=SUM(A1:Z1) with a formula at Z1) and silences two clippy lints in the same function. Co-Authored-By: Claude --- crates/engine/src/cmd/formulas.rs | 20 +++++++++++++------- crates/ui-egui/tests/trace_navigation.rs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 0607901ff..c1287f988 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,6 +1,6 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. -use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{CalcMode, DefinedName, Formula}; use serde_json::{Value as Json, json}; @@ -485,7 +485,6 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { /// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]). fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result { use std::collections::BTreeSet; - const CAP: usize = 4096; let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let sheet = d.wb.active_sheet; @@ -495,15 +494,23 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result = BTreeSet::new(); let mut frontier = vec![(sheet, at)]; seen.insert((sheet, at)); - loop { - let Some((ks, kc)) = frontier.pop() else { break }; + while let Some((ks, kc)) = frontier.pop() { if precedents { for a in d.calc.graph.precedents_of((ks, kc)) { if all { - for c in a.range.iter().take(CAP) { + // Walk the sheet's stored cells inside the precedent area: blanks are + // never selected and only formula cells extend the frontier, so a + // formula late in a large range is not skipped. + let Some(sh) = d.wb.sheet(a.sheet) else { continue }; + for (c, cell) in sh.cells.iter() { + if !a.range.contains(c) || matches!(cell.value, Value::Empty) { + continue; + } if seen.insert((a.sheet, c)) { found.insert((a.sheet, c.a1())); - frontier.push((a.sheet, c)); + if cell.formula.is_some() { + frontier.push((a.sheet, c)); + } } } } else { @@ -538,7 +545,6 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result Date: Mon, 19 Oct 2026 19:47:07 +0000 Subject: [PATCH 3/3] perf(engine): scan only the precedent area's stored cells iter_range walks the sparse store's bands for the area's row range only, so a chain of N formulas no longer rescans every stored cell of the sheet per area. Co-Authored-By: Claude --- crates/engine/src/cmd/formulas.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index c1287f988..b9fd86a59 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -498,12 +498,13 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result