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 083fa9c..7f1ad87 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -336,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/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 7307156..e2447e9 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -537,6 +537,25 @@ fn column_autocomplete_skips_ambiguous_prefixes_and_numbers() { 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);