UI: AutoComplete for cell values can be turned off, Backspace and Escape drop the proposal (fixes #842)

This commit is contained in:
Marco Arnulfo
2026-10-13 00:13:35 +02:00
parent fc45b7f6d8
commit 7a8235a586
5 changed files with 97 additions and 2 deletions
+6
View File
@@ -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() {
+1
View File
@@ -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}"),
+8 -1
View File
@@ -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))
{
+9 -1
View File
@@ -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<String>,
/// 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);
+73
View File
@@ -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<String>) {
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<u8> {
let mut wb = gridcraft_engine::model::Workbook::new();