diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index c6795e9..3a3d82c 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -35,6 +35,8 @@ pub struct EditState { pub request_focus: bool, /// The text changed programmatically: push `caret` into the widget on the next frame. pub sync_caret: bool, + /// Column AutoComplete: the full entry the typed text completes to. + pub completion: Option, /// Move the caret to the end on the next frame. pub caret_to_end: bool, } @@ -56,6 +58,7 @@ impl EditState { request_focus: true, sync_caret: true, caret_to_end: true, + completion: None, } } @@ -272,3 +275,46 @@ mod tests { assert_eq!(r[3].3.as_deref(), Some("Sheet2")); } } + +/// Excel-style AutoComplete: the one distinct text entry in the same column (contiguous data +/// above and below) that starts with `typed` (case-insensitive). `None` when ambiguous. +pub fn column_completion(sheet: &sheetcraft_engine::model::Sheet, at: CellRef, typed: &str) -> Option { + if typed.is_empty() || typed.starts_with('=') || typed.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') { + return None; + } + let lower = typed.to_lowercase(); + let mut found: Option = None; + let mut ambiguous = false; + let mut scan = |row: u32| -> bool { + match sheet.value(CellRef::new(row, at.col)) { + sheetcraft_engine::core::Value::Empty => false, + sheetcraft_engine::core::Value::Text(t) => { + if t.to_lowercase().starts_with(&lower) && t.len() > typed.len() { + match &found { + Some(f) if !f.eq_ignore_ascii_case(&t) => ambiguous = true, + None => found = Some(t.to_string()), + _ => {} + } + } + true + } + _ => true, + } + }; + let mut r = at.row; + let mut n = 0; + while r > 0 && n < 5000 { + r -= 1; + n += 1; + if !scan(r) { + break; + } + } + let mut r = at.row + 1; + n = 0; + while n < 5000 && scan(r) { + r += 1; + n += 1; + } + if ambiguous { None } else { found } +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index a539b3a..66cb684 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -285,6 +285,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.caret = r.primary.index.into(); } if resp.changed() { + ed.completion = None; + if !ed.is_formula() + && ed.caret == ed.text.chars().count() + && let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) + { + ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text); + } ed.point = None; let names: Vec = function_names(); ed.update_autocomplete(&names); @@ -296,6 +303,21 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: sheetcraft_engine::Mode::Edit }; } + // 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 pos = resp.rect.left_top() + egui::vec2(output.galley.rect.width(), 0.0); + let g = ui.painter().layout_no_wrap( + rest.to_string(), + output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(), + Color32::WHITE, + ); + let r = egui::Rect::from_min_size(pos, g.size()); + ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8)); + ui.painter().galley(pos, g, Color32::WHITE); + } // Autocomplete list and argument hint under the editor. if mine && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4d16f0b..149222a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -280,7 +280,11 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - self.editor = Some(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); + if replace && !ed.is_formula() { + ed.completion = editor::column_completion(sh, at, &ed.text); + } + self.editor = Some(ed); self.session.mode = if replace { sheetcraft_engine::Mode::Enter } else { sheetcraft_engine::Mode::Edit }; } @@ -288,7 +292,10 @@ impl SheetApp { pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { let Some(ed) = self.editor.take() else { return true }; self.session.mode = sheetcraft_engine::Mode::Ready; - let text = ed.text.clone(); + let text = match &ed.completion { + Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), + _ => ed.text.clone(), + }; // Data validation. if let Some(d) = self.session.active() && let Some((dv, msg)) = sheetcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 1485cc3..d5cba8b 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -111,3 +111,17 @@ fn dialogs_open_and_close() { h.state_mut().message = None; } } + +#[test] +fn column_autocomplete_completes_on_enter() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["North"], ["East"], ["Eastern"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A4"})).unwrap(); + let mut h = harness(s); + text(&mut h, "No"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "A4"), Value::from("North")); + text(&mut h, "Ea"); // ambiguous: East / Eastern + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "A5"), Value::from("Ea")); +}