diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index d667c95..922baab 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -962,6 +962,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 d27ea05..2ea5cfa 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/editor.rs b/crates/ui-egui/src/editor.rs index 767f134..4aada29 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -251,9 +251,10 @@ pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, ty match sheet.value(CellRef::new(row, at.col)) { gridcraft_engine::core::Value::Empty => false, gridcraft_engine::core::Value::Text(t) => { - if t.to_lowercase().starts_with(&lower) && t.len() > typed.len() { + let folded = t.to_lowercase(); + if folded.starts_with(&lower) && folded.len() > lower.len() { match &found { - Some(f) if !f.eq_ignore_ascii_case(&t) => ambiguous = true, + Some(f) if f.to_lowercase() != folded => ambiguous = true, None => found = Some(t.to_string()), _ => {} } @@ -281,6 +282,13 @@ pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, ty if ambiguous { None } else { found } } +/// The part of `full` after the `typed` prefix, counted in characters: case folding can change +/// a character's byte length ("ß" is two bytes, "ẞ" three). +pub fn completion_rest<'a>(full: &'a str, typed: &str) -> Option<&'a str> { + let start = full.char_indices().nth(typed.chars().count())?.0; + full.get(start..) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 83278b1..7f1ad87 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)) { @@ -329,8 +336,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: } // Column AutoComplete: the rest of the suggested entry, shown selected after the caret. if let Some(full) = &ed.completion - && full.len() > ed.text.len() - && let Some(rest) = full.get(ed.text.len()..) + && let Some(rest) = crate::editor::completion_rest(full, &ed.text) { let pos = resp.rect.left_top() + egui::vec2(output.galley.rect.width(), 0.0); let g = ui.painter().layout_no_wrap( diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9b9511e..061698d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -583,6 +583,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Options|General", "全般"), ("Options|Save", "保存"), ("Show formula bar", "数式バーを表示する"), + ("Enable AutoComplete for cell values", "セルの値にオートコンプリートを使用する"), ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), ("Workbook calculation", "ブックの計算"), ("Enable iterative calculation", "反復計算を行う"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e7c9ca6..7ed8372 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -319,6 +319,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Obecné", "Options|Save" => "Uložit", "Show formula bar" => "Zobrazit řádek vzorců", + "Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk", "Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně", "Workbook calculation" => "Výpočet sešitu", "Enable iterative calculation" => "Povolit iterativní výpočet", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 6ceabdf..c20c7d1 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -319,6 +319,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Allgemein", "Options|Save" => "Speichern", "Show formula bar" => "Bearbeitungsleiste anzeigen", + "Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren", "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", "Workbook calculation" => "Arbeitsmappenberechnung", "Enable iterative calculation" => "Iterative Berechnung aktivieren", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 6812cb4..4ce231b 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -318,6 +318,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "General", "Options|Save" => "Guardar", "Show formula bar" => "Mostrar barra de fórmulas", + "Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda", "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio", "Workbook calculation" => "Cálculo del libro", "Enable iterative calculation" => "Habilitar cálculo iterativo", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 34370b7..5d20ab0 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Options|Général", "Options|Save" => "Options|Enregistrer", "Show formula bar" => "Afficher la barre de formule", + "Enable AutoComplete for cell values" => "Activer la saisie semi-automatique des valeurs de cellule", "Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification", "Workbook calculation" => "Calcul du classeur", "Enable iterative calculation" => "Activer le calcul itératif", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 9e5a85e..1902ad3 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "일반", "Options|Save" => "저장", "Show formula bar" => "수식 입력줄 표시", + "Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용", "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", "Workbook calculation" => "통합 문서 계산", "Enable iterative calculation" => "반복 계산 사용", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index dcf6563..fb9236c 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Geral", "Options|Save" => "Salvar", "Show formula bar" => "Mostrar barra de fórmulas", + "Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula", "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", "Workbook calculation" => "Cálculo da pasta de trabalho", "Enable iterative calculation" => "Habilitar cálculo iterativo", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 5b7cd11..765f6bb 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Общие", "Options|Save" => "Сохранение", "Show formula bar" => "Показывать строку формул", + "Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек", "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index fea3387..cbf18d4 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -397,6 +397,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Opcije|Opšte", "Options|Save" => "Opcije|Sačuvaj", "Show formula bar" => "Prikaži traku formule", + "Enable AutoComplete for cell values" => "Omogući automatsko dovršavanje vrednosti ćelija", "Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene", "Workbook calculation" => "Izračunavanje radne sveske", "Enable iterative calculation" => "Omogući iterativno izračunavanje", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index f30fcc9..0017592 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -397,6 +397,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "常规", "Options|Save" => "保存", "Show formula bar" => "显示编辑栏", + "Enable AutoComplete for cell values" => "为单元格值启用记忆式键入", "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", "Workbook calculation" => "工作簿计算", "Enable iterative calculation" => "启用迭代计算", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be03199..f96cdac 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, @@ -600,6 +604,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") { @@ -883,7 +891,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 d136b46..b40769c 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -655,6 +655,98 @@ 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_folds_case_beyond_ascii() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Ébène"], ["Ébano"], ["ébano"], ["STRAẞENBAHN"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A5"})).unwrap(); + let mut h = harness(s); + text(&mut h, "éba"); + assert_eq!(completion(&h), ("éba".into(), Some("ébano".into())), "Ébano and ébano are one entry, Ébène doesn't match"); + h.state_mut().cancel_edit(); + text(&mut h, "éb"); + assert_eq!(completion(&h).1, None, "Ébène and Ébano differ past the prefix"); + h.state_mut().cancel_edit(); + // "ß" is two bytes, "ẞ" three: the proposed rest starts after the fifth character, not the sixth byte. + text(&mut h, "straß"); + assert_eq!(completion(&h), ("straß".into(), Some("STRAẞENBAHN".into()))); + let painted = h.output().shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.job.text == "ENBAHN")); + assert!(painted, "the rest \"ENBAHN\" is drawn after the caret"); +} + +#[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();