mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-13 15:09:05 +00:00
Merge pull request #848 from marcoarnulfo:feat/cell-autocomplete
This commit is contained in:
commit
84acf6293b
16 files changed
+137
-6
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", "反復計算を行う"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "반복 계산 사용",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "Включить итеративные вычисления",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "启用迭代计算",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user