diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 4aada2953..4837f8bd7 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -144,6 +144,44 @@ impl EditState { true } + /// Ctrl+Shift+A, as in Excel: right after a function name, or after its `(`, inserts the + /// argument names (optional ones without their brackets) and the closing parenthesis, and + /// the `(` too when missing. Anywhere else nothing changes and it returns false. + pub fn insert_argument_names(&mut self) -> bool { + if !self.is_formula() { + return false; + } + let caret_b = self.byte_caret(); + let (Some(before), Some(after)) = (self.text.get(..caret_b), self.text.get(caret_b..)) else { return false }; + // An odd number of quotes before the caret: it is inside a text literal. + if before.matches('"').count() % 2 == 1 { + return false; + } + let open = before.ends_with('('); + if !open && after.starts_with('(') { + return false; + } + let head = before.strip_suffix('(').unwrap_or(before); + let name: String = + head.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::().chars().rev().collect(); + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { + return false; + } + let Some(signature) = crate::formula_locale::signature(&name, self.locale) else { return false }; + let sep = self.locale.list_separator(); + let inner = signature.split_once('(').map(|(_, rest)| rest.trim_end().trim_end_matches(')')).unwrap_or(""); + let args: Vec<&str> = inner.split(sep).map(|a| a.trim().trim_start_matches('[').trim_end_matches(']')).filter(|a| !a.is_empty()).collect(); + let close = if open && after.starts_with(')') { "" } else { ")" }; + let inserted = format!("{}{}{close}", if open { "" } else { "(" }, args.join(&sep.to_string())); + self.text = format!("{before}{inserted}{after}"); + self.caret += inserted.chars().count(); + self.autocomplete.clear(); + self.completion = None; + self.caret_to_end = false; + self.sync_caret = true; + true + } + /// The function call the caret is inside and the argument index (for the hint tooltip). pub fn current_function(&self) -> Option<(String, usize)> { if !self.is_formula() { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..da58c6504 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -255,6 +255,22 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) { cycle_anchor(&mut ed); } + // Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere, + // as in Excel, so it never falls through to the text's select-all. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) { + let (text, caret) = (ed.text.clone(), ed.caret); + if ed.insert_argument_names() + && let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) + { + // The text edit records an undo point only once typing pauses: mark the text + // before the shortcut so one Undo takes back just the inserted names. + let mut undoer = state.undoer(); + let cursor = state.cursor.char_range().unwrap_or(egui::text::CCursorRange::one(egui::text::CCursor::new(caret))); + undoer.add_undo(&(cursor, text)); + state.set_undoer(undoer); + state.store(ui.ctx(), id); + } + } } // The text widget. let refs = formula_refs(&ed.text); diff --git a/crates/ui-egui/tests/argument_names.rs b/crates/ui-egui/tests/argument_names.rs new file mode 100644 index 000000000..1702cf839 --- /dev/null +++ b/crates/ui-egui/tests/argument_names.rs @@ -0,0 +1,90 @@ +//! Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name inserts its argument names, as +//! in Excel. +use egui::{Context, Event, Key, Modifiers, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +fn frame(ctx: &Context, app: &mut SheetApp, time: f64, events: Vec) { + let mut output = ctx.run_ui( + RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), time: Some(time), events, ..Default::default() }, + |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + ); + output.textures_delta.clear(); +} + +fn key(key: Key, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +fn ctrl_shift_a() -> Event { + key(Key::A, Modifiers::CTRL | Modifiers::SHIFT) +} + +/// A cell editor holding `text`, caret at the end. +fn editing(text: &str) -> (Context, SheetApp) { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + for t in 0..4 { + frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]); + } + app.begin_edit(Some(text.into()), false); + for t in 4..8 { + frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]); + } + (ctx, app) +} + +fn text(app: &SheetApp) -> &str { + app.editor.as_ref().map(|e| e.text.as_str()).unwrap_or("") +} + +#[test] +fn after_the_opening_parenthesis_inserts_the_argument_names() { + let (ctx, mut app) = editing("=SUMIF("); + frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), "=SUMIF(range,criteria,sum_range)"); +} + +#[test] +fn after_a_bare_function_name_also_adds_the_parentheses() { + for (typed, expected) in + [("=SUMIF", "=SUMIF(range,criteria,sum_range)"), ("=1+sumif", "=1+sumif(range,criteria,sum_range)"), ("=TODAY", "=TODAY()")] + { + let (ctx, mut app) = editing(typed); + frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), expected, "after {typed}"); + } +} + +#[test] +fn anywhere_else_the_shortcut_changes_nothing() { + for typed in ["=SUMIF(A1", "=SUMIF(A1,", "=NOTAFUNCTION", "=A1+", "=\"SUMIF(", "SUMIF("] { + let (ctx, mut app) = editing(typed); + frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), typed, "after {typed}"); + } +} + +#[test] +fn one_undo_takes_back_the_inserted_names_only() { + let (ctx, mut app) = editing("=SUMI"); + // Typed just before the shortcut: the text edit's own undo has not settled on it yet. + frame(&ctx, &mut app, 0.1, vec![Event::Text("F(".into())]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), "=SUMIF("); + frame(&ctx, &mut app, 0.12, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.13, vec![]); + assert_eq!(text(&app), "=SUMIF(range,criteria,sum_range)"); + frame(&ctx, &mut app, 0.14, vec![key(Key::Z, Modifiers::COMMAND)]); + frame(&ctx, &mut app, 0.15, vec![]); + assert_eq!(text(&app), "=SUMIF("); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..6a0194322 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Ctrl+Shift+A inserts a function's argument names) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -124,7 +124,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | +| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name, or after its `(`, inserts the argument names and the closing parenthesis, as in Excel; one Undo in the editor takes them back | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |