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

This commit is contained in:
Brandon Thomas committed 2026-10-13 02:04:03 -04:00
commit 84acf6293b
16 files changed
+137 -6

No files matched your search

+6
View File
@@ -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() {
+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}"),
+10 -2
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)) {
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::*;
+9 -3
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))
{
@@ -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(
+1
View File
@@ -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", "反復計算を行う"),
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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" => "반복 계산 사용",
+1
View File
@@ -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",
+1
View File
@@ -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" => "Включить итеративные вычисления",
+1
View File
@@ -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",
+1
View File
@@ -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" => "启用迭代计算",
+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,
@@ -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);
+92
View File
@@ -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<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.
fn macro_workbook_bytes() -> Vec<u8> {
let mut wb = gridcraft_engine::model::Workbook::new();