From 3557b9f6cd12b8cfc06d8261e86b94a05edd9ca9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:15:48 -0700 Subject: [PATCH] Formulas: F3 opens Paste Name, which inserts a defined name or pastes the list of names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formulas.pasteName lists the names the active sheet can use (workbook names and the sheet's own, without hidden names, in alphabetical order). With name it puts the name at the caret of the formula being edited, replacing selected text, or starts =Name; with list: true it writes each name and the text of what it refers to in two columns from the active cell. The Paste Name dialog opens from F3 when the workbook has names, from Use in Formula's new Paste Names… item and from Alt+M, S, P. While a dialog is open over the edit, the editor takes no keys. Use in Formula lists the same names, and formulas.useInFormula inserts at the caret instead of replacing the text being edited. --- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/cmd/formulas.rs | 147 +++++++++++++++- crates/engine/src/lib.rs | 3 + crates/ui-egui/src/dialogs.rs | 79 +++++++++ crates/ui-egui/src/editor.rs | 44 +++++ crates/ui-egui/src/formula_bar.rs | 34 +++- crates/ui-egui/src/keymap.rs | 1 + .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 7 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 22 ++- crates/ui-egui/tests/it/keytips_generated.rs | 5 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/paste_names.rs | 161 ++++++++++++++++++ docs/ui-parity.md | 8 +- 15 files changed, 492 insertions(+), 25 deletions(-) create mode 100644 crates/ui-egui/tests/it/paste_names.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 009d77f2a..8ec8c795b 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1265,7 +1265,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result { fn use_in_formula(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; - s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}")))); + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); ok() } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 7718279b2..8a09d42f0 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; +use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -55,6 +55,15 @@ pub fn specs() -> Vec { has_doc, create_from_selection ), + cmd!( + "formulas.pasteName", + "Paste Names…", + ["Formulas", "Defined Names"], + Some("F3"), + "{name?: \"Sales\" (OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.", + has_doc, + paste_name + ), cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents), cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents), @@ -510,6 +519,58 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { }) } +/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the +/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the +/// workbook name it shares. +pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> { + let sheet = wb.active_sheet; + let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet)); + let local: Vec<&str> = shown(true).map(|n| n.name.as_str()).collect(); + let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect(); + for n in shown(false) { + if !local.iter().any(|x| gridcraft_formula::same_name(x, &n.name)) { + out.push((n.name.clone(), format!("={}", n.formula))); + } + } + out.sort_by_cached_key(|(n, _)| n.to_lowercase()); + out +} + +fn paste_name(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let names = pasteable_names(&d.wb); + let listed: Vec = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect(); + if !bool_param(p, "list").unwrap_or(false) { + if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); + } + return Ok(json!({"names": listed})); + } + if names.is_empty() { + return Err(EngineError::Other("This workbook has no names to paste.".into())); + } + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let rows = u32::try_from(names.len()).unwrap_or(u32::MAX); + if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS { + return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into())); + } + let sheet = d.wb.active_sheet; + let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1)); + edit(s, |cx| { + for (row, (name, refers)) in (at.row..).zip(&names) { + for (col, text) in [(at.col, name), (at.col + 1, refers)] { + let c = CellRef::new(row, col); + let sh = cx.sheet_mut(sheet)?; + let style = sh.style_id(c); + // The second column holds the refers-to text as a text value, with no formula. + sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style }); + cx.touch(sheet, c); + } + } + Ok(json!({"names": listed, "range": range.a1()})) + }) +} + fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); @@ -877,4 +938,88 @@ mod tests { // The union parses as one, so the preview is what the union gives in a cell, not #NAME?. assert_eq!(u["value"], json!({"error": "#VALUE!"})); } + + /// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook + /// `Tax`, and a name of another sheet. + fn named_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap(); + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let name = |name: &str, scope, formula: &str, hidden| DefinedName { + name: name.into(), + scope, + formula: formula.into(), + comment: String::new(), + hidden, + }; + wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true)); + wb.names.push(name("TAX", Some(0), "0.25", false)); + wb.names.push(name("Other", Some(1), "1", false)); + s + } + + fn text_at(s: &Session, cell: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) + } + + #[test] + fn paste_names_lists_the_names_the_active_sheet_can_use() { + let mut s = named_workbook(); + let r = s.execute("formulas.pasteName", json!({})).unwrap(); + assert_eq!( + r["names"], + json!([ + {"name": "rate", "refersTo": "=0.07"}, + {"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"}, + {"name": "TAX", "refersTo": "=0.25"}, + ]) + ); + assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing"); + } + + #[test] + fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() { + let mut s = named_workbook(); + s.take_ui_requests(); + s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap(); + s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap(); + assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]); + assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered"); + } + + #[test] + fn paste_list_writes_each_name_and_what_it_refers_to() { + let mut s = named_workbook(); + s.execute("selection.set", json!({"range": "D2"})).unwrap(); + let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap(); + assert_eq!(r["range"], json!("D2:E4")); + let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")]; + for (cell, text) in rows { + assert_eq!(text_at(&s, cell), Value::text(text), "{cell}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text"); + s.execute("edit.undo", json!({})).unwrap(); + for (cell, _) in rows { + assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo"); + } + s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap(); + assert_eq!(text_at(&s, "B7"), Value::text("rate")); + } + + #[test] + fn paste_list_needs_names_and_room() { + let mut s = named_workbook(); + let last = format!("A{}", gridcraft_core::MAX_ROWS - 1); + assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row"); + let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1)); + assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column"); + let mut empty = Session::new(); + empty.new_workbook(); + assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err()); + assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([])); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4ab60f3a..a3929a02c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -372,6 +372,9 @@ pub enum UiRequest { Message(String), /// Start editing the active cell (F2) with optional initial text. EditCell(Option), + /// Put a defined name at the caret of the cell being edited, in place of selected text; with + /// no edit under way, start editing the active cell with `=` and the name. + InsertName(String), OpenUrl(String), } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..3aa97aff9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -824,6 +824,17 @@ impl Dialog { Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]})) } "nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})), + "pasteName" => { + // F3 opens it only when the workbook has names to paste, as in Excel. + let names = pasteable_names(app); + if p("shortcut") == json!(true) && names.is_empty() { + return None; + } + // OK runs `formulas.pasteName` with the selected `name`, so `ui.dialog.confirm` does too. + let mut dialog = Dialog::custom("pasteName", "Paste Name", json!({"name": names.first()})); + dialog.command = Some("formulas.pasteName"); + dialog + } "manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})), "commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})), "agents" => Dialog::custom("agents", "Agent Control", json!({})), @@ -1264,6 +1275,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), "sort" => sort(app, ui, &mut d, &mut confirm), "nameManager" => name_manager(app, ui, &mut d, &mut open), + "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), "manageRules" => manage_rules(app, ui), "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), @@ -2496,6 +2508,73 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu }); } +/// The names Paste Names offers in the active workbook. +fn pasteable_names(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default() +} + +/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it +/// starts one in the active cell with `=` and the name. +pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { + match app.editor.as_mut() { + Some(ed) => ed.insert_at_caret(name), + None => app.begin_edit(Some(format!("={name}")), false), + } +} + +/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the +/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it +/// refers to from the active cell. +fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names = pasteable_names(app); + // The `name` value is the selection, so `ui.dialog.set` can choose one too. + if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { + d.list_index = i; + } + let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); + let last = names.len().saturating_sub(1); + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + d.list_index = d.list_index.min(last); + ui.label(app.ui.language.tr("Paste name")); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + if names.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); + } + for (i, n) in names.iter().enumerate() { + let r = ui.selectable_label(i == d.list_index, n); + if (down || up) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + *confirm = true; + } + } + }); + ui.add_space(4.0); + // Paste List is disabled while a cell is being edited. + if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { + app.run_or_alert("formulas.pasteName", json!({"list": true})); + *open = false; + return; + } + if let Some(n) = names.get(d.list_index) { + d.values.insert("name".into(), json!(n)); + } + ok_cancel(ui, confirm, open); +} + /// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. const ALL_CHARTS: [(&str, &str, &str); 20] = [ ("column", "", "Clustered Column"), diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8984ad53c..3259ac4c4 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -28,6 +28,9 @@ pub struct EditState { pub from_formula_bar: bool, /// Caret position (char index), mirrored from the text widget. pub caret: usize, + /// The other end of the selected text (char index), mirrored like `caret`; equal to `caret` + /// when nothing is selected. + pub anchor: usize, /// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it. pub point: Option<(usize, usize)>, /// Where point mode's keyboard cursor is. @@ -39,6 +42,9 @@ pub struct EditState { pub request_focus: bool, /// The text changed programmatically: push `caret` into the widget on the next frame. pub sync_caret: bool, + /// A dialog opened over the edit has the keyboard: when the editor takes it back without a + /// change to the caret, the selected text (`anchor`..`caret`) stays selected. + pub keep_selection: bool, /// Column AutoComplete: the full entry the typed text completes to. pub completion: Option, /// Move the caret to the end on the next frame. @@ -56,6 +62,7 @@ impl EditState { enter_mode, from_formula_bar, caret, + anchor: caret, point: None, point_cell: None, point_anchor: None, @@ -63,6 +70,7 @@ impl EditState { ac_index: 0, request_focus: true, sync_caret: true, + keep_selection: false, caret_to_end: true, completion: None, } @@ -112,6 +120,27 @@ impl EditState { self.sync_caret = true; } + /// Inserts `t` at the caret as typed text (Paste Names), in place of the selected text: the + /// caret ends after it, and point mode starts afresh. + pub fn insert_at_caret(&mut self, t: &str) { + // When code moved the caret this frame (`sync_caret`), the widget has not yet stored it, so `anchor` is out of + // date and the name goes in at the caret. + let (from, to) = if self.sync_caret { (self.caret, self.caret) } else { (self.anchor.min(self.caret), self.anchor.max(self.caret)) }; + let byte = |c: usize| self.text.char_indices().nth(c).map(|(i, _)| i).unwrap_or(self.text.len()); + let (Some(a), Some(b)) = (self.text.get(..byte(from)), self.text.get(byte(to)..)) else { return }; + self.text = format!("{a}{t}{b}"); + self.point = None; + self.caret = from + t.chars().count(); + self.anchor = self.caret; + // A function suggestion shown before the name went in no longer fits the text. + self.autocomplete.clear(); + self.ac_index = 0; + self.completion = None; + self.caret_to_end = false; + self.sync_caret = true; + self.request_focus = 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) { @@ -379,6 +408,21 @@ mod tests { assert!(!e2.can_point()); } + #[test] + fn a_pasted_name_replaces_the_selected_text() { + let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false); + e.sync_caret = false; + (e.anchor, e.caret) = (8, 1); + e.autocomplete = vec!["SUM".into()]; + e.insert_at_caret("Sales"); + assert_eq!((e.text.as_str(), e.caret, e.anchor), ("=Sales+1", 6, 6)); + assert!(e.autocomplete.is_empty(), "the earlier function suggestion is gone"); + // Nothing selected: the name goes in at the caret. + e.sync_caret = false; + e.insert_at_caret("Rate"); + assert_eq!((e.text.as_str(), e.caret), ("=SalesRate+1", 10)); + } + #[test] fn autocomplete_and_hints() { let names: Vec = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..5ec8c4a87 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -184,7 +184,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let mut cancel = false; // A message box over the editor (a data validation alert, a formula error) gets Enter and Esc; // the editor used to take them, so the first Esc cancelled the edit behind the message. - if has_focus && mine && app.message.is_none() { + // So does a dialog opened from the editor (Paste Name): its OK takes Enter. + if has_focus && mine && app.message.is_none() && app.dialog.is_none() { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { let m = i.modifiers; let alt_enter = @@ -274,6 +275,10 @@ 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); } + // F3 opens Paste Name when the workbook has names; OK puts the name at the caret. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) { + app.open_dialog("pasteName", json!({"shortcut": true})); + } // 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)) { @@ -315,6 +320,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.fonts_mut(|f| f.layout_job(job)) }; let chars_before = ed.text.chars().count(); + // A dialog opened over the edit (Paste Name) takes the keyboard until it closes, and the + // editor takes it back afterwards. + let dialog_open = app.dialog.is_some(); + if dialog_open && mine { + ed.request_focus = true; + ed.keep_selection = true; + } // The in-cell editor is as tall as its lines (a multiline field reserves four rows). let in_cell = id == egui::Id::new("gridcraft.cell_editor"); let edit = if multiline || ed.text.contains('\n') { @@ -329,18 +341,26 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: .desired_width(width) .layouter(&mut layouter) .lock_focus(true) + .interactive(!dialog_open) .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) .show(ui); let resp = output.response.clone(); - let synced = mine && (ed.request_focus || ed.sync_caret); + let synced = mine && !dialog_open && (ed.request_focus || ed.sync_caret); if synced { if ed.request_focus { resp.request_focus(); } ed.request_focus = false; + // Closing a dialog that changed nothing (Cancel) gives the selection back as it was. + let range = if ed.keep_selection && !ed.sync_caret { + egui::text::CCursorRange::two(egui::text::CCursor::new(ed.anchor), egui::text::CCursor::new(ed.caret)) + } else { + ed.anchor = ed.caret; + egui::text::CCursorRange::one(egui::text::CCursor::new(ed.caret)) + }; ed.sync_caret = false; - let cursor = egui::text::CCursor::new(ed.caret); - output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(cursor))); + ed.keep_selection = false; + output.state.cursor.set_char_range(Some(range)); output.state.store(ui.ctx(), id); } else if resp.clicked() || resp.gained_focus() { // Clicking into the other editor moves editing there. @@ -348,8 +368,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.enter_mode = false; app.session.mode = gridcraft_engine::Mode::Edit; } - if !synced && let Some(r) = output.cursor_range { + if !synced + && !dialog_open + && let Some(r) = output.cursor_range + { ed.caret = r.primary.index.into(); + ed.anchor = r.secondary.index.into(); } if resp.changed() { ed.completion = None; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5840036a1..424ab0355 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -157,6 +157,7 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})), F3 if cmd => Shortcut::Dialog("nameManager", json!({})), F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})), + F3 if plain => Shortcut::Dialog("pasteName", json!({"shortcut": true})), F4 if cmd && !s => run("file.close"), F5 if plain && s => Shortcut::Dialog("find", json!({})), F5 if plain => Shortcut::Dialog("goTo", json!({})), diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index d94128de7..c35a3bbad 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -243,7 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents ("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions) ("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula) - ("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands + ("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names ("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text) ("MU", Entry::Continue), // Excel: AutoSum Menu; group level: ("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 96bdc075a..5be64f867 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1666,11 +1666,10 @@ }, { "seq": "MSP", - "kind": "command", - "target": "formulas.pasteName", + "kind": "dialog", + "target": "pasteName", "excel": "Paste Names", - "note": "reserved until the command lands", - "pending": true + "note": "" }, { "seq": "MT", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14c442331..9c5b560b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -748,6 +748,7 @@ impl SheetApp { }); self.begin_edit(text, false); } + UiRequest::InsertName(name) => dialogs::insert_name(self, &name), UiRequest::OpenUrl(u) => { if let Some(f) = &self.services.open_url { f(&u); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..f6b098cf6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1971,15 +1971,25 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { app.open_dialog("defineName", json!({})); } let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true); - anchor_keytip(app, ui, &use_in, &["formulas.pasteName"]); + anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]); egui::Popup::menu(&use_in).show(|ui| { - let names: Vec = app.session.active().map(|d| d.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); - for n in names { - if ui.button(&n).clicked() { - let cur = app.editor.as_ref().map(|e| e.text.clone()).unwrap_or_else(|| "=".into()); - app.begin_edit(Some(format!("{cur}{n}")), false); + // The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret. + let names: Vec = app + .session + .active() + .map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default(); + for n in &names { + if ui.button(n).clicked() { + app.run_or_alert("formulas.useInFormula", json!({"name": n})); } } + if !names.is_empty() { + ui.separator(); + } + if ui.button(app.ui.language.tr("Paste Names…")).clicked() { + app.open_dialog("pasteName", json!({})); + } }); let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false); control_keytip(app, ui, &__k, "formulas.createFromSelection"); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index d3cb84cf7..2dbcb8f29 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1783,10 +1783,7 @@ fn keytip_ms_() { fn keytip_msp_() { let mut h = harness(blank()); sequence(&mut h, "MSP", false); - assert!(h.state().dialog.is_none(), "MSP must not open a dialog"); - assert!(h.state().message.is_none(), "MSP must not open a message box"); - assert!(h.state().toast.is_none(), "MSP must not raise a toast"); - assert!(h.state().session.journal.is_empty(), "MSP must not journal"); + assert!(h.state().dialog.is_some(), "MSP must open the pasteName dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..0b429cbf0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -59,6 +59,7 @@ mod open_encrypted; mod outline; mod page_layout_view; mod page_setup_margins; +mod paste_names; mod paste_special_dialog; mod pivot_pane; mod pivot_show_details; diff --git a/crates/ui-egui/tests/it/paste_names.rs b/crates/ui-egui/tests/it/paste_names.rs new file mode 100644 index 000000000..79ef59936 --- /dev/null +++ b/crates/ui-egui/tests/it/paste_names.rs @@ -0,0 +1,161 @@ +//! F3 opens the Paste Name dialog when the workbook has names: OK puts the selected name into the +//! formula being edited, and Paste List writes every name and what it refers to, as in Excel. + +use egui::{Event, Key, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(names: bool) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + if names { + session.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap(); + session.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.07"})).unwrap(); + } + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn dialog(h: &Harness) -> Option<&str> { + h.state().dialog.as_ref().map(|d| d.name()) +} + +fn editor_text(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.text.clone()) +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn press(h: &mut Harness, key: Key) { + h.key_press(key); + h.run_steps(3); +} + +#[test] +fn f3_opens_paste_name_only_when_the_workbook_has_names() { + let mut h = harness(false); + press(&mut h, Key::F3); + assert_eq!(dialog(&h), None, "no names: F3 does nothing"); + let mut h = harness(true); + press(&mut h, Key::F3); + assert_eq!(dialog(&h), Some("pasteName")); + // The names are listed in alphabetical order. + let rate = h.get_by_label("Rate").rect().top(); + let sales = h.get_by_label("Sales").rect().top(); + assert!(rate < sales, "Rate is listed above Sales"); +} + +#[test] +fn ok_starts_a_formula_with_the_selected_name() { + let mut h = harness(true); + press(&mut h, Key::F3); + h.get_by_label("Sales").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(editor_text(&h).as_deref(), Some("=Sales")); +} + +#[test] +fn the_control_channel_confirms_the_dialog_like_ok() { + let mut h = harness(true); + let ctx = h.ctx.clone(); + for (method, params) in + [("ui.dialog", json!({"name": "pasteName"})), ("ui.dialog.set", json!({"field": "name", "value": "Sales"})), ("ui.dialog.confirm", json!({}))] + { + match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => assert_eq!(v["ok"], json!(true), "{method}: {v}"), + Outcome::Screenshot { .. } => panic!("{method} took a screenshot"), + } + } + h.run_steps(2); + assert_eq!(dialog(&h), None); + assert_eq!(editor_text(&h).as_deref(), Some("=Sales")); +} + +#[test] +fn arrow_keys_choose_the_name_and_leave_the_active_cell() { + let mut h = harness(true); + press(&mut h, Key::F3); + press(&mut h, Key::ArrowDown); + press(&mut h, Key::Enter); + assert_eq!(dialog(&h), None); + assert_eq!(editor_text(&h).as_deref(), Some("=Sales"), "Down moved from Rate to Sales"); + let active = h.state().session.active().unwrap().selection.active; + assert_eq!(active, CellRef::new(0, 0), "the arrow went to the dialog, not the grid"); +} + +#[test] +fn while_editing_the_name_goes_in_at_the_caret_and_the_edit_goes_on() { + let mut h = harness(true); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(4); + press(&mut h, Key::F3); + assert_eq!(dialog(&h), Some("pasteName"), "F3 in the cell editor opens the dialog"); + // While the dialog is open, typed text and arrow keys do not change the formula being edited. + h.input_mut().events.push(Event::Text("x".into())); + h.run_steps(2); + press(&mut h, Key::ArrowLeft); + assert_eq!(editor_text(&h).as_deref(), Some("=SUM("), "the formula is unchanged while the dialog is open"); + h.get_by_label("Sales").click(); + h.run_steps(2); + press(&mut h, Key::Enter); + assert_eq!(dialog(&h), None, "Enter is the dialog's OK"); + assert_eq!(editor_text(&h).as_deref(), Some("=SUM(Sales"), "the cell is still being edited"); + assert_eq!(value(&h, "A1"), Value::Empty, "nothing was entered yet"); +} + +#[test] +fn cancel_leaves_the_selected_text_selected() { + let mut h = harness(true); + h.state_mut().begin_edit(Some("=SUM(A1".into()), false); + // Edit mode (F2): the arrow keys move the caret instead of leaving the cell. + if let Some(ed) = h.state_mut().editor.as_mut() { + ed.enter_mode = false; + } + h.run_steps(4); + for _ in 0..2 { + h.key_press_modifiers(egui::Modifiers::SHIFT, Key::ArrowLeft); + h.run_steps(2); + } + press(&mut h, Key::F3); + assert_eq!(dialog(&h), Some("pasteName")); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + h.input_mut().events.push(Event::Text("B2".into())); + h.run_steps(2); + assert_eq!(editor_text(&h).as_deref(), Some("=SUM(B2"), "typing replaces the text selected before the dialog opened"); +} + +#[test] +fn paste_list_writes_the_names_from_the_active_cell() { + let mut h = harness(true); + h.state_mut().run("selection.set", json!({"range": "C2"})).unwrap(); + h.run_steps(2); + press(&mut h, Key::F3); + h.get_by_label("Paste List").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(value(&h, "C2"), Value::text("Rate")); + assert_eq!(value(&h, "D2"), Value::text("=0.07")); + assert_eq!(value(&h, "C3"), Value::text("Sales")); + assert_eq!(value(&h, "D3"), Value::text("=Sheet1!$A$1:$A$3")); + assert_eq!(editor_text(&h), None); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..405a0c661 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (F3 Paste Name dialog with Paste List; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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 @@ -84,7 +84,7 @@ this logic, with one undo step. | Shortcuts bound to commands | 200+ documented shortcuts per platform | audited below (#110): shortcuts whose feature exists are bound, with the exceptions listed; the rest wait for their features | | Ctrl+arrow, Ctrl+Shift+arrow, Ctrl+Home/End, Ctrl+Shift+Home/End, Page Up/Down, Alt+Page | yes | done | | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done | -| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing | +| F2, F3, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F4 Repeat missing | | Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) | | Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; each Borders letter (H, B, …) draws its own preset, H, B, M opens Format Cells on the Border tab, and Erase Border, Line Color and Line Style are reserved; QAT/Backstage excluded | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | @@ -133,7 +133,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | | 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) | +| F3 | not checked | Paste Name dialog, only when the workbook has names: OK puts the name at the caret of the formula being edited (or starts `=Name`), Paste List writes each name and what it refers to from the active cell (`formulas.pasteName`) | **added** | +| F1, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | | Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, 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+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | @@ -178,6 +179,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | F3, Formulas › Use in Formula › Paste Names… and Alt+M, S, P open the Paste Name dialog (F3 only when the workbook has names). It lists the workbook's names and the active sheet's own in alphabetical order, hidden names left out; OK or a double-click puts the selected name at the caret of the formula being edited, in place of selected text, or starts `=Name` in the active cell (`formulas.pasteName` `{name}`), and Paste List writes each name and the text of what it refers to in two columns from the active cell (`{list: true}`). While a dialog is open over the edit, the cell editor takes no keys, and Cancel leaves the selected text selected. The Use in Formula menu lists the same names and inserts at the caret too (`formulas.useInFormula`); it used to list hidden names and other sheets' names, the menu added the name at the end of the text, and the command replaced the text being edited | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |