Merge pull request #848 from marcoarnulfo:feat/cell-autocomplete

This commit is contained in:
Brandon Thomas 2026-10-13 02:04:03 -04:00
commit 84acf6293b
16 changed files with 137 additions and 6 deletions

View File

@ -962,6 +962,12 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
} }
ui.add_space(18.0); 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"); section(ui, lang, "Options|Save");
let mut autosave = app.grid.autosave(); let mut autosave = app.grid.autosave();
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {

View File

@ -181,6 +181,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("view.darkMode", "Dark Mode", ""), ("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""), ("view.theme", "Display Theme", ""),
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("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)"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""), ("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),

View File

@ -251,9 +251,10 @@ pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, ty
match sheet.value(CellRef::new(row, at.col)) { match sheet.value(CellRef::new(row, at.col)) {
gridcraft_engine::core::Value::Empty => false, gridcraft_engine::core::Value::Empty => false,
gridcraft_engine::core::Value::Text(t) => { 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 { 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()), 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 } 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

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 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 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); 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) (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift)
}); });
if alt_enter { 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 esc {
if !ed.autocomplete.is_empty() { if !ed.autocomplete.is_empty() {
ed.autocomplete.clear(); ed.autocomplete.clear();
} else if ed.completion.is_some() {
ed.completion = None;
} else { } else {
cancel = true; cancel = true;
} }
@ -305,7 +311,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
} }
if resp.changed() { if resp.changed() {
ed.completion = None; ed.completion = None;
if !ed.is_formula() if app.ui.autocomplete
&& !ed.is_formula()
&& ed.caret == ed.text.chars().count() && ed.caret == ed.text.chars().count()
&& let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) && 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. // Column AutoComplete: the rest of the suggested entry, shown selected after the caret.
if let Some(full) = &ed.completion if let Some(full) = &ed.completion
&& full.len() > ed.text.len() && let Some(rest) = crate::editor::completion_rest(full, &ed.text)
&& let Some(rest) = full.get(ed.text.len()..)
{ {
let pos = resp.rect.left_top() + egui::vec2(output.galley.rect.width(), 0.0); let pos = resp.rect.left_top() + egui::vec2(output.galley.rect.width(), 0.0);
let g = ui.painter().layout_no_wrap( let g = ui.painter().layout_no_wrap(

View File

@ -583,6 +583,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Options|General", "全般"), ("Options|General", "全般"),
("Options|Save", "保存"), ("Options|Save", "保存"),
("Show formula bar", "数式バーを表示する"), ("Show formula bar", "数式バーを表示する"),
("Enable AutoComplete for cell values", "セルの値にオートコンプリートを使用する"),
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
("Workbook calculation", "ブックの計算"), ("Workbook calculation", "ブックの計算"),
("Enable iterative calculation", "反復計算を行う"), ("Enable iterative calculation", "反復計算を行う"),

View File

@ -319,6 +319,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Obecné", "Options|General" => "Obecné",
"Options|Save" => "Uložit", "Options|Save" => "Uložit",
"Show formula bar" => "Zobrazit řádek vzorců", "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ě", "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", "Workbook calculation" => "Výpočet sešitu",
"Enable iterative calculation" => "Povolit iterativní výpočet", "Enable iterative calculation" => "Povolit iterativní výpočet",

View File

@ -319,6 +319,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Allgemein", "Options|General" => "Allgemein",
"Options|Save" => "Speichern", "Options|Save" => "Speichern",
"Show formula bar" => "Bearbeitungsleiste anzeigen", "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", "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung", "Workbook calculation" => "Arbeitsmappenberechnung",
"Enable iterative calculation" => "Iterative Berechnung aktivieren", "Enable iterative calculation" => "Iterative Berechnung aktivieren",

View File

@ -318,6 +318,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "General", "Options|General" => "General",
"Options|Save" => "Guardar", "Options|Save" => "Guardar",
"Show formula bar" => "Mostrar barra de fórmulas", "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", "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro", "Workbook calculation" => "Cálculo del libro",
"Enable iterative calculation" => "Habilitar cálculo iterativo", "Enable iterative calculation" => "Habilitar cálculo iterativo",

View File

@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Options|Général", "Options|General" => "Options|Général",
"Options|Save" => "Options|Enregistrer", "Options|Save" => "Options|Enregistrer",
"Show formula bar" => "Afficher la barre de formule", "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", "Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"Workbook calculation" => "Calcul du classeur", "Workbook calculation" => "Calcul du classeur",
"Enable iterative calculation" => "Activer le calcul itératif", "Enable iterative calculation" => "Activer le calcul itératif",

View File

@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "일반", "Options|General" => "일반",
"Options|Save" => "저장", "Options|Save" => "저장",
"Show formula bar" => "수식 입력줄 표시", "Show formula bar" => "수식 입력줄 표시",
"Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용",
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산", "Workbook calculation" => "통합 문서 계산",
"Enable iterative calculation" => "반복 계산 사용", "Enable iterative calculation" => "반복 계산 사용",

View File

@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Geral", "Options|General" => "Geral",
"Options|Save" => "Salvar", "Options|Save" => "Salvar",
"Show formula bar" => "Mostrar barra de fórmulas", "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", "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", "Workbook calculation" => "Cálculo da pasta de trabalho",
"Enable iterative calculation" => "Habilitar cálculo iterativo", "Enable iterative calculation" => "Habilitar cálculo iterativo",

View File

@ -405,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Общие", "Options|General" => "Общие",
"Options|Save" => "Сохранение", "Options|Save" => "Сохранение",
"Show formula bar" => "Показывать строку формул", "Show formula bar" => "Показывать строку формул",
"Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек",
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге", "Workbook calculation" => "Вычисления в книге",
"Enable iterative calculation" => "Включить итеративные вычисления", "Enable iterative calculation" => "Включить итеративные вычисления",

View File

@ -397,6 +397,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Opcije|Opšte", "Options|General" => "Opcije|Opšte",
"Options|Save" => "Opcije|Sačuvaj", "Options|Save" => "Opcije|Sačuvaj",
"Show formula bar" => "Prikaži traku formule", "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", "Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene",
"Workbook calculation" => "Izračunavanje radne sveske", "Workbook calculation" => "Izračunavanje radne sveske",
"Enable iterative calculation" => "Omogući iterativno izračunavanje", "Enable iterative calculation" => "Omogući iterativno izračunavanje",

View File

@ -397,6 +397,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "常规", "Options|General" => "常规",
"Options|Save" => "保存", "Options|Save" => "保存",
"Show formula bar" => "显示编辑栏", "Show formula bar" => "显示编辑栏",
"Enable AutoComplete for cell values" => "为单元格值启用记忆式键入",
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
"Workbook calculation" => "工作簿计算", "Workbook calculation" => "工作簿计算",
"Enable iterative calculation" => "启用迭代计算", "Enable iterative calculation" => "启用迭代计算",

View File

@ -57,6 +57,9 @@ pub struct UiState {
pub status_bar: bool, pub status_bar: bool,
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
pub animations: bool, 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>, pub recent: Vec<String>,
/// One-shot keytip request for a ribbon control: set when a keytip action resolves to /// 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. /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
@ -99,6 +102,7 @@ impl Default for UiState {
formula_bar_expanded: false, formula_bar_expanded: false,
status_bar: true, status_bar: true,
animations: true, animations: true,
autocomplete: true,
recent: vec![], recent: vec![],
keytip_invoke: None, keytip_invoke: None,
language: i18n::Language::En, 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); self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations);
Ok(json!({"on": 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" => { "view.interfaceScale" => {
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
let percent = match p.get("percent") { 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); let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar);
ed.locale = locale; 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); ed.completion = editor::column_completion(sh, at, &ed.text);
} }
self.editor = Some(ed); self.editor = Some(ed);

View File

@ -655,6 +655,98 @@ fn column_autocomplete_completes_on_enter() {
assert_eq!(value(&h, "A5"), Value::from("Ea")); 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_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. /// An .xlsm carrying a VBA project, saved to bytes.
fn macro_workbook_bytes() -> Vec<u8> { fn macro_workbook_bytes() -> Vec<u8> {
let mut wb = gridcraft_engine::model::Workbook::new(); let mut wb = gridcraft_engine::model::Workbook::new();