diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2e0dbc593 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; readiness unchanged) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/contributors/people.toml b/contributors/people.toml index 0bc376396..dca3f43ef 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,6 @@ sync_github_name = true real_name = "Ufuk Efe" display_name = "Ufuk Efe" sync_github_name = true + +[people.pourie-he] +sync_github_name = true diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 799109f89..02d2428f9 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -32,6 +32,8 @@ pub struct EditState { pub point: Option<(usize, usize)>, /// Where point mode's keyboard cursor is. pub point_cell: Option, + /// The fixed range anchor and its sheet while pointing with the mouse or keyboard. + pub point_anchor: Option<(usize, CellRef)>, pub autocomplete: Vec, pub ac_index: usize, pub request_focus: bool, @@ -56,6 +58,7 @@ impl EditState { caret, point: None, point_cell: None, + point_anchor: None, autocomplete: vec![], ac_index: 0, request_focus: true, @@ -81,9 +84,7 @@ impl EditState { if !self.is_formula() { return false; } - if let Some((_, e)) = self.point - && e == self.byte_caret() - { + if self.point_at_caret() { return true; } let before = self.text.get(..self.byte_caret()).unwrap_or(""); @@ -91,6 +92,10 @@ impl EditState { matches!(last, Some('=' | '(' | ',' | '+' | '-' | '*' | '/' | '^' | '&' | '<' | '>' | ':' | ';' | '{')) } + pub fn point_at_caret(&self) -> bool { + self.point.is_some_and(|(start, end)| end == self.byte_caret() && self.text.get(start..end).is_some()) + } + /// Inserts (or replaces the last pointed) reference text at the caret. pub fn insert_ref(&mut self, r: &str) { let caret_b = self.byte_caret(); @@ -107,6 +112,17 @@ impl EditState { self.sync_caret = true; } + /// Add another argument after the current pointed reference. Keep only the new reference + /// replaceable, so a subsequent plain click does not remove earlier arguments or the separator. + pub fn append_ref(&mut self, r: &str) { + let separator = if self.point_at_caret() { self.locale.list_separator().to_string() } else { String::new() }; + self.point = None; + self.insert_ref(&format!("{separator}{r}")); + if let Some((start, end)) = self.point { + self.point = Some((start + separator.len(), end)); + } + } + /// Updates function autocomplete for the word before the caret. pub fn update_autocomplete(&mut self, names: &[String]) { self.autocomplete.clear(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index e39c4fdfe..a44369c42 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -321,6 +321,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text); } ed.point = None; + ed.point_anchor = None; let names: Vec = function_names() .iter() .map(|name| { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 8067cd58c..e070dc2b3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2095,14 +2095,26 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if let Some(ed) = app.editor.as_mut() && ed.can_point() { + let anchor = if mods.shift && ed.point_at_caret() { + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c) + } else { + c + }; + let range = RangeRef::new(anchor, c); + let text = if range.is_single() { c.a1() } else { range.a1() }; let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone()); let r = match sheet_name { - Some(n) => format!("{}!{}", gridcraft_engine::formula::quote_sheet(&n), c.a1()), - None => c.a1(), + Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), + None => text, }; - ed.insert_ref(&r); + if mods.command && !mods.shift { + ed.append_ref(&r); + } else { + ed.insert_ref(&r); + } ed.request_focus = true; ed.point_cell = Some(c); + ed.point_anchor = Some((wb.active_sheet, anchor)); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2364,6 +2376,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) let r = range.a1(); let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; ed.insert_ref(&r); + ed.point_anchor = None; ed.request_focus = true; true } @@ -2376,12 +2389,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { - if let Some(start) = ed.point_cell { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) { let range = RangeRef::new(start, cell); let text = if range.is_single() { range.start.a1() } else { range.a1() }; let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; ed.insert_ref(&text); - ed.point_cell = Some(start); + ed.point_cell = Some(cell); + ed.point_anchor = Some((wb.active_sheet, start)); } } else if let Some(d) = app.session.active() { // Read the current anchor: starting a drag may have just replaced the selection. @@ -2972,11 +2986,11 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte let Some(d) = app.session.active() else { return }; let from = ed.point_cell.unwrap_or(ed.cell); let to = from.offset_clamped(dr, dc); - let text = if extend { + let anchor = if extend { // The anchor is the corner opposite the moving end: the written range is normalized, so // after extending up or left its first cell is the moving end, not the anchor. let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); - let anchor = pointed + let inferred_anchor = pointed .map(|r| { CellRef::new( if from.row == r.start.row { r.end.row } else { r.start.row }, @@ -2984,13 +2998,15 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ) }) .unwrap_or(from); - RangeRef::new(anchor, to).a1() + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor) } else { - to.a1() + to }; - let _ = d; + let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let sheet = d.wb.active_sheet; ed.insert_ref(&text); ed.point_cell = Some(to); + ed.point_anchor = Some((sheet, anchor)); app.grid.ensure_visible = true; } diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs index faebebf00..e168f3d25 100644 --- a/crates/ui-egui/tests/selection.rs +++ b/crates/ui-egui/tests/selection.rs @@ -53,6 +53,16 @@ fn press(h: &mut Harness, cell: &str) { frame(h, button(from, true)); } +fn click_with_modifiers(h: &mut Harness, cell: &str, modifiers: Modifiers) { + let pos = point(h, cell); + frame(h, Event::ModifiersChanged(modifiers)); + frame(h, Event::PointerMoved(pos)); + for pressed in [true, false] { + frame(h, Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + } + h.run_steps(2); +} + fn assert_selection(h: &Harness, start: &str, end: &str) { let selection = &h.state().session.active().unwrap().selection; let start = CellRef::parse(start).unwrap(); @@ -134,7 +144,8 @@ fn formula_point_mode_consumes_first_movement_and_release() { h.run_steps(1); let editor = h.state().editor.as_ref().unwrap(); assert_eq!(editor.text, "=C3:F7"); - assert_eq!(editor.point_cell, CellRef::parse("C3")); + assert_eq!(editor.point_cell, CellRef::parse("F7")); + assert_eq!(editor.point_anchor, Some((0, CellRef::parse("C3").unwrap()))); assert_eq!(cells(&h), before); } @@ -159,3 +170,119 @@ fn formula_point_drag_keeps_the_sheet_qualifier() { h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); assert_eq!(cells(&h), before); } + +#[test] +fn formula_modifier_command_click_appends_an_argument() { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + let command = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_modifier_shift_click_extends_the_pointed_range() { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3:F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_modifier_command_click_keeps_previous_arguments_replaceable_independently() { + for command in [Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }, Modifiers { ctrl: true, command: true, ..Modifiers::NONE }] { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "D4", command); + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,F7"); + click_with_modifiers(&mut h, "G7", Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,G7"); + click_with_modifiers(&mut h, "F9", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,F7:G9"); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn formula_modifier_shift_click_keeps_its_anchor_in_both_directions() { + for (start, first, second, expected) in [("C3", "F7", "B2", "B2:C3"), ("F7", "C3", "H9", "F7:H9")] { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, start, Modifiers::NONE); + click_with_modifiers(&mut h, first, Modifiers::SHIFT); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("=SUM({expected}")); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn formula_modifier_command_click_respects_locale_and_a_typed_separator() { + use gridcraft_engine::formula::FormulaLocale; + let command = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + for (locale, prefix, separator) in [(FormulaLocale::En, "=SUM(", ","), (FormulaLocale::Es, "=SUMA(", ";")] { + let mut h = harness("J10"); + h.state_mut().begin_edit(Some(prefix.into()), false); + h.state_mut().editor.as_mut().unwrap().locale = locale; + h.run_steps(2); + click_with_modifiers(&mut h, "C3", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3")); + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3{separator}F7")); + frame(&mut h, Event::ModifiersChanged(Modifiers::NONE)); + frame(&mut h, Event::Text(separator.into())); + h.run_steps(2); + assert!(h.state().editor.as_ref().unwrap().point_anchor.is_none()); + click_with_modifiers(&mut h, "G8", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3{separator}F7{separator}G8")); + } +} + +#[test] +fn formula_modifier_shift_click_does_not_reuse_an_anchor_from_another_sheet() { + let mut h = harness("J10"); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM('Other sheet'!F7"); + click_with_modifiers(&mut h, "D4", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM('Other sheet'!D4:F7"); +} + +#[test] +fn formula_modifier_shift_click_then_arrow_moves_from_the_clicked_endpoint() { + use egui::Key; + for (modifiers, expected) in [(Modifiers::SHIFT, "=SUM(C3:G7"), (Modifiers::NONE, "=SUM(G7")] { + let mut h = harness("J10"); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + frame(&mut h, Event::ModifiersChanged(modifiers)); + for pressed in [true, false] { + frame(&mut h, Event::Key { key: Key::ArrowRight, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); + assert_eq!(h.state().editor.as_ref().unwrap().text, expected); + assert_eq!(h.state().editor.as_ref().unwrap().point_cell, CellRef::parse("G7")); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6834df529..9204c8500 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-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Ctrl/Option-drag a sheet tab drops a copy, #971) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Ctrl/Option-drag a sheet tab drops a copy, #971; formula-entry Command/Ctrl-click adds arguments and Shift-click extends a range) · **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 @@ -49,7 +49,7 @@ this logic, with one undo step. | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | @@ -166,6 +166,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 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-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. | | 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 | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) |