From 7a8235a5863e92c38cb3d5dfd8c713c89a7df007 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:13:35 +0200 Subject: [PATCH] UI: AutoComplete for cell values can be turned off, Backspace and Escape drop the proposal (fixes #842) --- crates/ui-egui/src/backstage.rs | 6 +++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/formula_bar.rs | 9 +++- crates/ui-egui/src/lib.rs | 10 +++- crates/ui-egui/tests/interaction.rs | 73 +++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ae2155419..54144c9b0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -824,6 +824,12 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); } ui.add_space(18.0); + section(ui, lang, "Options|Editing"); + let mut autocomplete = app.ui.autocomplete; + if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { + app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); + } + ui.add_space(18.0); section(ui, lang, "Options|Save"); let mut autosave = app.grid.autosave(); if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b753b00e8..4a8bd3090 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -181,6 +181,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), + ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 83278b190..083fa9c73 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -173,6 +173,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let enter = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::SHIFT, Key::Enter); let tab = i.consume_key(egui::Modifiers::NONE, Key::Tab) || i.consume_key(egui::Modifiers::SHIFT, Key::Tab); let esc = i.consume_key(egui::Modifiers::NONE, Key::Escape); + // Backspace takes back the AutoComplete proposal (Excel's selected rest), not a typed character. + if ed.completion.is_some() && i.consume_key(egui::Modifiers::NONE, Key::Backspace) { + ed.completion = None; + } (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift) }); if alt_enter { @@ -198,6 +202,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if esc { if !ed.autocomplete.is_empty() { ed.autocomplete.clear(); + } else if ed.completion.is_some() { + ed.completion = None; } else { cancel = true; } @@ -305,7 +311,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: } if resp.changed() { ed.completion = None; - if !ed.is_formula() + if app.ui.autocomplete + && !ed.is_formula() && ed.caret == ed.text.chars().count() && let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b19b2551..2f4cda43f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -57,6 +57,9 @@ pub struct UiState { pub status_bar: bool, /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). pub animations: bool, + /// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed + /// from the one matching entry in the same column. + pub autocomplete: bool, pub recent: Vec, /// One-shot keytip request for a ribbon control: set when a keytip action resolves to /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. @@ -99,6 +102,7 @@ impl Default for UiState { formula_bar_expanded: false, status_bar: true, animations: true, + autocomplete: true, recent: vec![], keytip_invoke: None, language: i18n::Language::En, @@ -497,6 +501,10 @@ impl SheetApp { self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); Ok(json!({"on": self.ui.animations})) } + "options.autoComplete" => { + self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); + Ok(json!({"on": self.ui.autocomplete})) + } "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { @@ -780,7 +788,7 @@ impl SheetApp { }; let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar); ed.locale = locale; - if replace && !ed.is_formula() { + if replace && self.ui.autocomplete && !ed.is_formula() { ed.completion = editor::column_completion(sh, at, &ed.text); } self.editor = Some(ed); diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 709393389..73071568b 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -485,6 +485,79 @@ fn column_autocomplete_completes_on_enter() { assert_eq!(value(&h, "A5"), Value::from("Ea")); } +fn completion(h: &egui_kittest::Harness<'static, SheetApp>) -> (String, Option) { + let ed = h.state().editor.as_ref().expect("still editing"); + (ed.text.clone(), ed.completion.clone()) +} + +#[test] +fn column_autocomplete_backspace_and_escape_drop_only_the_proposal() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Apple"], ["Banana"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A3"})).unwrap(); + let mut h = harness(s); + text(&mut h, "ap"); + assert_eq!(completion(&h), ("ap".into(), Some("Apple".into())), "\"ple\" is proposed after the caret"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "A3"), Value::from("Apple"), "Enter accepts it, with the entry's own case"); + text(&mut h, "ba"); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(value(&h, "A4"), Value::from("Banana"), "Tab accepts it too"); + + // Backspace removes the proposal, not the typed "a". + h.state_mut().run("selection.set", json!({"cell": "A5"})).unwrap(); + h.run_steps(2); + text(&mut h, "ba"); + key(&mut h, Key::Backspace, Modifiers::NONE); + assert_eq!(completion(&h), ("ba".into(), None)); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "A5"), Value::from("ba")); + + // Escape dismisses the proposal and keeps the edit; a second Escape cancels it. + text(&mut h, "ap"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(completion(&h), ("ap".into(), None)); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "A6"), Value::Empty); +} + +#[test] +fn column_autocomplete_skips_ambiguous_prefixes_and_numbers() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Apricot"], ["Apple"], ["12 Main St"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A4"})).unwrap(); + let mut h = harness(s); + text(&mut h, "ap"); + assert_eq!(completion(&h), ("ap".into(), None), "Apricot and Apple both match"); + text(&mut h, "p"); + assert_eq!(completion(&h), ("app".into(), Some("Apple".into())), "one more letter settles it"); + h.state_mut().cancel_edit(); + text(&mut h, "12"); + assert_eq!(completion(&h), ("12".into(), None), "a number is not completed"); +} + +#[test] +fn column_autocomplete_can_be_turned_off() { + assert!(gridcraft_ui_egui::UiState::default().autocomplete); + let old: gridcraft_ui_egui::UiState = serde_json::from_str(r#"{"dark": true}"#).unwrap(); + assert!(old.autocomplete, "a ui.json written before the setting existed keeps it on"); + + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["North"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + let mut h = harness(s); + assert_eq!(h.state_mut().run("options.autoComplete", json!({"on": false})), Ok(json!({"on": false}))); + assert!(h.state().dialog.is_none()); + text(&mut h, "No"); + assert_eq!(completion(&h), ("No".into(), None)); + text(&mut h, "r"); + assert_eq!(completion(&h), ("Nor".into(), None), "typing on doesn't bring it back"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "A2"), Value::from("Nor")); + assert_eq!(h.state_mut().run("options.autoComplete", json!({})), Ok(json!({"on": true})), "no parameters: it toggles"); +} + /// An .xlsm carrying a VBA project, saved to bytes. fn macro_workbook_bytes() -> Vec { let mut wb = gridcraft_engine::model::Workbook::new();