diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 86929372c..5b22184f5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -144,13 +144,13 @@ pub fn specs() -> Vec { fill_series ), cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill), - cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find), + cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find), cmd!( "edit.replace", "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), - "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", has_doc, replace ), diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 0d4656320..ddb9ecaf7 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let by_cols = bool_param(p, "byColumns").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(false); let workbook = str_param(p, "within") == Some("workbook"); + // `selection`: only the selected cells of the active sheet, as Excel searches a selected range. + let in_selection = str_param(p, "within") == Some("selection"); s.last_find = Some(p.clone()); let d = s.doc()?; let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; @@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let mut cells: Vec<(CellRef, String)> = sh .cells .iter() + .filter(|(c, _)| !in_selection || d.selection.contains(*c)) .filter_map(|(c, cell)| { let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { crate::display::cell_text(&d.wb, sh, c) @@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; let dd = s.doc_mut()?; dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); + if in_selection { + // The range stays selected and the active cell moves through it, as in Excel. + dd.selection.active = c; + } else { + dd.selection = crate::Selection::at(c); + } Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) } @@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let whole = bool_param(p, "wholeCell").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(true); let workbook = str_param(p, "within") == Some("workbook"); + let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?; let active = s.doc()?.selection.active; let mut skipped = 0usize; let n = edit(s, |cx| { @@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); for (c, text) in cells { - if !all && c != active { + if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) { continue; } if !matches(&text, &what, case, whole) { diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 346703d9c..49317caa0 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -552,3 +552,31 @@ fn a_field_that_is_not_really_quoted_keeps_its_separators() { let hostile = "\"x\t".repeat(50_000); assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001)); } + +/// `within: selection` searches only the selected cells, as Excel does with a range selected +/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range +/// selected, and Replace changes only them. +#[test] +fn find_and_replace_within_the_selection() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]})) + .unwrap(); + s.execute("selection.set", json!({"range": "B1:B4"})).unwrap(); + let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap(); + let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect(); + assert_eq!(cells, ["B1", "B2", "B4"], "only column B"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range"); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected"); + assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range"); + assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3); + let sh = s.doc().unwrap().wb.active().unwrap(); + let at = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear"))); + // The sheet scope is unchanged. + let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap(); + assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet"); +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index c1401d554..fe7f8ad6c 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -40,7 +40,16 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { d.values.insert("matchCase".into(), json!(case)); d.values.insert("wholeCell".into(), json!(whole)); d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); - let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); + // As in Excel, a selected range (more than one cell) limits the search on the sheet to it. + let range_selected = app.session.active().is_some_and(|doc| !doc.selection.is_single_cell()); + let within = if wb { + "workbook" + } else if range_selected { + "selection" + } else { + "sheet" + }; + let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": within}); ui.horizontal(|ui| { if d.tab == "Replace" { if ui.button("Replace All").clicked() { diff --git a/crates/ui-egui/tests/it/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs index a774a431b..a66bb7acb 100644 --- a/crates/ui-egui/tests/it/find_dialog_focus.rs +++ b/crates/ui-egui/tests/it/find_dialog_focus.rs @@ -34,3 +34,27 @@ fn typing_right_after_ctrl_f_fills_find_what() { fn typing_right_after_ctrl_h_fills_find_what() { assert_eq!(type_after_shortcut(Key::H), Some(json!("abc"))); } + +/// With a range selected, Find All in the dialog lists only the matches inside it, as in Excel +/// (#3224); with one cell selected it searches the whole sheet. +#[test] +fn find_all_with_a_range_selected_searches_only_the_range() { + use egui_kittest::kittest::Queryable; + for (range, expected) in [("B1:B3", 2), ("C5", 4)] { + let mut h = harness(); + let app = h.state_mut(); + app.session.execute("range.setValues", json!({"range": "A1:B3", "values": [["pear", "pear"], ["fig", "fig"], ["pear", "pear"]]})).unwrap(); + app.session.execute("selection.set", json!({"range": range})).unwrap(); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: CTRL }); + h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: CTRL }); + h.run_steps(6); + h.input_mut().events.push(Event::Text("pear".into())); + h.run_steps(4); + h.get_by_label("Find All").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().expect("find dialog is open"); + let count = d.result.as_ref().map(|r| r["count"].clone()); + assert_eq!(count, Some(json!(expected)), "{range} selected"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e4bf03ea1..8aea28dae 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -238,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Find and Replace with a range selected searches only that range, as in Excel: Find Next moves the active cell through it and keeps it selected, Find All lists its matches, Replace and Replace All change only them; one selected cell still searches the sheet. `edit.find` / `edit.replace` take `within: selection` (#3224) | | 2026-11-03 | minor | Point mode: dragging over row or column headers extends the whole-row or whole-column reference, as it does over cells | | 2026-11-03 | minor | Frozen panes: text in frozen columns overflows only into its own pane and keeps its whole width while the sheet scrolls; frozen gridlines and merged-cell borders reach the freeze line, and the freeze handle measures the last frozen column itself (#2544). Page Layout view gridlines stop at each page's last column | | 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) |