Merge storytold/gridcraft#2410: Formulas: F3 opens Paste Name, which inserts a defined name or pastes the list of names

This commit is contained in:
aiongg
2026-10-25 02:24:49 +00:00
15 changed files with 492 additions and 25 deletions
+1 -1
View File
@@ -1270,7 +1270,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
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()
}
+146 -1
View File
@@ -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<CommandSpec> {
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<Json> {
})
}
/// 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<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = 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<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
@@ -881,4 +942,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!([]));
}
}
+3
View File
@@ -372,6 +372,9 @@ pub enum UiRequest {
Message(String),
/// Start editing the active cell (F2) with optional initial text.
EditCell(Option<String>),
/// 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),
}
+79
View File
@@ -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<String> {
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"),
+44
View File
@@ -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<String>,
/// 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<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
+29 -5
View File
@@ -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;
+1
View File
@@ -157,6 +157,7 @@ pub fn lookup(key: Key, physical: Option<Key>, 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!({})),
@@ -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)
+3 -4
View File
@@ -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",
+1
View File
@@ -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);
+16 -6
View File
@@ -1988,15 +1988,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<String> = 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<String> = 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");
+1 -4
View File
@@ -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]
+1
View File
@@ -60,6 +60,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;
+161
View File
@@ -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<String> {
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, &params) {
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);
}
+5 -3
View File
@@ -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
@@ -85,7 +85,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) |
@@ -134,7 +134,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 |
@@ -179,6 +180,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) |