M3: column AutoComplete, spelling dialog, Page Break Preview, icons gallery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 01:14:18 -04:00
parent 168b8ec8b7
commit 433726773b
4 changed files with 91 additions and 2 deletions

View File

@ -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<String>,
/// 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<String> {
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<String> = 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 }
}

View File

@ -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<String> = 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);

View File

@ -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)

View File

@ -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"));
}