diff --git a/ROADMAP.md b/ROADMAP.md index 328b92f40..e6e34ecea 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-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used follows the formulas entered; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) | | 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | @@ -245,6 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories | | 2026-10-31 | minor | Progress log: Function Library | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..0000ff608 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -20,7 +20,7 @@ mod paste_special; mod selection; use cell::{cell_set, range_fill, range_set_values}; -pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula}; +pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula}; #[cfg(test)] pub(crate) use clipboard::parse_tsv; use clipboard::{clear_clipboard, copy, cut, paste}; diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index c57afae97..562d5c73d 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -5,6 +5,16 @@ use super::{ // ---------------------------------------------------------------- entry +/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or +/// a parenthesis (not a number), as Excel takes `+SUM(1)`. +pub(crate) fn is_formula_input(input: &str) -> bool { + input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && gridcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')) +} + /// What typing `input` into a cell produces (constant or formula, plus an automatic number /// format). Inherits the destination's cell, row or column style, including its number /// format, before parsing. Errors when the formula can't be parsed. @@ -18,12 +28,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri if fmt_is_text { return Ok(Some(Cell { value: Value::text(input), formula: None, style })); } - let is_formula = input.starts_with('=') - || (input.len() > 1 - && (input.starts_with('+') || input.starts_with('-')) - && gridcraft_core::parse::parse_number_text(input).is_none() - && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); - if is_formula { + if is_formula_input(input) { let body = input.strip_prefix('=').unwrap_or(input); let expr = wb.name_call_case(parse_typed_formula(body)?); check_arg_counts(&expr)?; diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c9a1582a2..53c880a7c 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -310,7 +310,7 @@ pub fn specs() -> Vec { new_cell_style ), cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))), - cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used), + cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used), cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))), cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))), cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))), diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs index ff0d5b65b..795d7f011 100644 --- a/crates/engine/src/cmd/extra/cells.rs +++ b/crates/engine/src/cmd/extra/cells.rs @@ -122,25 +122,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result { }) } +/// Formulas › Recently Used: the functions most recently entered in a formula, newest first +/// ([`crate::Prefs::recent_functions`]). pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result { - let mut seen: Vec = Vec::new(); - for (id, p) in s.journal.iter().rev() { - if id != "cell.set" { - continue; - } - let input = p.get("input").and_then(Json::as_str).unwrap_or(""); - if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) { - for f in e.functions() { - if !seen.iter().any(|x| x == f) { - seen.push(f.to_string()); - } - } - } - if seen.len() >= 10 { - break; - } - } - Ok(json!(seen)) + Ok(json!(s.prefs.recent_functions)) } pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ae4ac95a..67f8efe08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; +pub mod recent; pub mod record; pub mod sample; pub mod selection; @@ -390,6 +391,10 @@ pub struct Prefs { pub formula_bar: bool, /// The statistics the status bar shows for a selection (right-click the status bar). pub status_bar: StatusBarStats, + /// Formulas › Recently Used: the functions most recently entered in a formula, newest first + /// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel. + #[serde(deserialize_with = "recent::lenient")] + pub recent_functions: Vec, } /// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on @@ -424,6 +429,7 @@ impl Default for Prefs { user_dictionary: vec![], formula_bar: true, status_bar: StatusBarStats::default(), + recent_functions: recent::defaults(), } } } @@ -790,6 +796,7 @@ impl Session { } if self.journal_paused == 0 { self.push_journal(id, cmd::journal_params(¶ms)); + recent::note(&mut self.prefs.recent_functions, id, ¶ms); } } // Inside an undo group the group owns the step. A command that ended a group open before diff --git a/crates/engine/src/recent.rs b/crates/engine/src/recent.rs new file mode 100644 index 000000000..0a1bba2dc --- /dev/null +++ b/crates/engine/src/recent.rs @@ -0,0 +1,205 @@ +//! Formulas › Recently Used: the functions most recently entered in a formula. +//! +//! The list is [`crate::Prefs::recent_functions`], an application list like Excel's, saved with +//! the other preferences. [`crate::Session::execute`] calls [`note`] for each `cell.*` and +//! `range.*` command it journals, from any source (the interface, the control channel, MCP or a +//! script), so the list never depends on the journal, which drops its oldest entries. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use crate::cmd::edit::is_formula_input; +use gridcraft_formula::lexer::{Tok, tokenize}; +use gridcraft_formula::normalize_function_name; +use serde::Deserialize; +use serde_json::Value as Json; + +/// How many functions the list keeps. +pub const MAX: usize = 10; +/// The longest formula read (Excel's formula length limit): a longer one was not typed. +const FORMULA_MAX: usize = 8_192; +/// The most formulas read from one command: a large paste needs only its last few. +const SCAN_BUDGET: usize = 2_000; + +/// What the list holds before any function has been used. +pub fn defaults() -> Vec { + ["SUM", "AVERAGE", "IF", "COUNT", "MAX", "MIN", "SUMIF", "XLOOKUP", "PMT", "TODAY"].map(String::from).to_vec() +} + +/// Whether `name` (upper case, without `_xlfn.`) is a function of the catalog. +fn known(name: &str) -> bool { + static NAMES: OnceLock> = OnceLock::new(); + NAMES + .get_or_init(|| crate::cmd::formulas::function_list().iter().filter_map(|f| f["name"].as_str().map(str::to_ascii_uppercase)).collect()) + .contains(name) +} + +/// The functions a formula calls, outermost first, known functions only. A formula is what a +/// cell takes as one (`=…`, or `+…`/`-…` as Excel takes `+SUM(1)`). +pub fn functions_in_formula(text: &str) -> Vec { + if !is_formula_input(text) { + return vec![]; + } + let body = text.strip_prefix('=').unwrap_or(text); + if body.len() > FORMULA_MAX { + return vec![]; + } + let mut out: Vec = Vec::new(); + for t in tokenize(body).unwrap_or_default() { + if let Tok::Func(name) = t.tok { + let name = normalize_function_name(&name); + if known(&name) && !out.contains(&name) { + out.push(name); + } + } + } + out +} + +/// The formula texts a command's params enter, the last first, at most `budget` of them. +fn formulas_newest_first<'a>(id: &str, params: &'a Json, budget: usize) -> Vec<&'a str> { + fn walk<'a>(v: &'a Json, out: &mut Vec<&'a str>, depth: usize, budget: usize) { + match v { + Json::String(s) if is_formula_input(s) && out.len() < budget => out.push(s), + // A range's values are rows of cells; anything deeper is not a cell value. + Json::Array(a) if depth < 2 => { + for x in a.iter().rev() { + if out.len() >= budget { + return; + } + walk(x, out, depth + 1, budget); + } + } + _ => {} + } + } + let mut out = Vec::new(); + if id.starts_with("cell.") || id.starts_with("range.") { + for key in ["formula", "values", "value", "input"] { + if let Some(v) = params.get(key) { + walk(v, &mut out, 0, budget); + } + } + } + out +} + +/// Moves `names` (most recent first) to the front of `recent`, keeping at most [`MAX`]. +pub fn push(recent: &mut Vec, names: &[String]) { + let mut next: Vec = Vec::with_capacity(MAX); + for n in names.iter().chain(recent.iter()) { + if next.len() == MAX { + break; + } + if !next.contains(n) { + next.push(n.clone()); + } + } + *recent = next; +} + +/// Moves the functions that the formulas a command entered call to the front of `recent`. The +/// last formulas are read first (they are the most recent), within [`SCAN_BUDGET`]. +pub(crate) fn note(recent: &mut Vec, id: &str, params: &Json) { + let mut names: Vec = Vec::new(); + for f in formulas_newest_first(id, params, SCAN_BUDGET) { + for n in functions_in_formula(f) { + if !names.contains(&n) { + names.push(n); + } + } + if names.len() >= MAX { + break; + } + } + if !names.is_empty() { + push(recent, &names); + } +} + +/// Reads a saved list leniently: known function names only, at most [`MAX`]. A value that is not +/// a list gives the default list, so a hand-edited or hostile `prefs.json` loses this list, never +/// the other preferences. +pub(crate) fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let raw = Json::deserialize(d)?; + let Some(list) = raw.as_array() else { return Ok(defaults()) }; + let mut out = Vec::new(); + for name in list.iter().filter_map(Json::as_str) { + if out.len() == MAX { + break; + } + let name = name.to_ascii_uppercase(); + if known(&name) && !out.contains(&name) { + out.push(name); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn formulas_name_their_functions_outermost_first() { + assert_eq!(functions_in_formula("=IF(SUM(A1:A3)>2,_xlfn.XLOOKUP(1,B:B,C:C),sum(1))"), ["IF", "SUM", "XLOOKUP"]); + assert!(functions_in_formula("=MYLAMBDA(1)+A1").is_empty(), "only known functions"); + assert!(functions_in_formula("SUM(1)").is_empty(), "not a formula"); + assert!(functions_in_formula("=\"SUM(\"").is_empty(), "inside a string"); + assert!(functions_in_formula(&format!("=SUM({})", "1+".repeat(9000))).is_empty(), "too long"); + assert!(functions_in_formula("=((((").is_empty()); + assert!(functions_in_formula("=").is_empty()); + // A formula may be typed with + or - instead of =, as input_to_cell takes it. + assert_eq!(functions_in_formula("+SUM(1)"), ["SUM"]); + assert_eq!(functions_in_formula("-ABS(A1)"), ["ABS"]); + assert!(functions_in_formula("-5").is_empty() && functions_in_formula("+1e3").is_empty(), "numbers"); + assert!(functions_in_formula("+").is_empty() && functions_in_formula("- SUM(1)").is_empty(), "not formulas"); + } + + #[test] + fn commands_give_their_formulas_newest_first() { + let read = |id: &str, params: Json| formulas_newest_first(id, ¶ms, SCAN_BUDGET).into_iter().map(String::from).collect::>(); + assert_eq!(read("range.setValues", json!({"values": [["=SUM(1)", 2], ["x", "=PMT(1,2,3)"]]})), ["=PMT(1,2,3)", "=SUM(1)"]); + assert_eq!(read("cell.set", json!({"input": "=NPV(1,2)"})), ["=NPV(1,2)"]); + assert_eq!(read("range.setValues", json!({"values": [["+SUM(1)", "-5", "-ABS(2)"]]})), ["-ABS(2)", "+SUM(1)"]); + assert!(read("home.bold", json!({"input": "=IRR(1)"})).is_empty(), "only cell.* and range.* enter formulas"); + assert!(read("range.setValues", json!({"values": [[["=FV(1,2,3)"]]]})).is_empty(), "deeper than rows of cells"); + // A large paste is read from its end, within the budget. + let rows: Vec = (0..5_000).map(|i| json!([format!("=ABS({i})")])).collect(); + let paste = json!({ "values": rows }); + let last = formulas_newest_first("range.setValues", &paste, 3); + assert_eq!(last, ["=ABS(4999)", "=ABS(4998)", "=ABS(4997)"]); + } + + #[test] + fn recent_keeps_ten_distinct_newest_first() { + let mut r = defaults(); + assert!(r.iter().all(|n| known(n)), "the default list names known functions"); + push(&mut r, &["PMT".into(), "NPV".into()]); + assert_eq!(r.len(), MAX); + assert_eq!(&r[..3], ["PMT", "NPV", "SUM"]); + assert_eq!(r.iter().filter(|n| *n == "PMT").count(), 1); + let many: Vec = + ["ABS", "ACOS", "AND", "CHOOSE", "DATE", "EXP", "FIND", "INDEX", "LEFT", "LN", "MOD", "NOW"].map(String::from).to_vec(); + push(&mut r, &many); + assert_eq!(r, many[..MAX]); + } + + #[test] + fn a_saved_list_is_read_leniently() { + let prefs = |v: Json| serde_json::from_value::(v).unwrap(); + assert_eq!(prefs(json!({"r1c1": true})).recent_functions, defaults(), "a prefs.json from before the list gets the default"); + // A hostile value loses only itself, never the other preferences. + let bad = prefs(json!({"r1c1": true, "recentFunctions": 5})); + assert!(bad.r1c1); + assert_eq!(bad.recent_functions, defaults()); + let mut odd: Vec = ["npv", "NOT_A_FUNCTION", "SUM", "NPV"].iter().map(|n| json!(n)).chain([json!(3), json!(null)]).collect(); + odd.extend(crate::cmd::formulas::function_list().into_iter().take(40).map(|f| f["name"].clone())); + let odd = prefs(json!({ "recentFunctions": odd })); + assert_eq!(&odd.recent_functions[..2], ["NPV", "SUM"]); + assert_eq!(odd.recent_functions.len(), MAX); + let back = prefs(serde_json::to_value(&odd).unwrap()); + assert_eq!(back.recent_functions, odd.recent_functions, "the list survives a save and reload"); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 20898e152..99ebd42b1 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -22,6 +22,7 @@ mod percent_entry; mod pivot_unknown_func; mod pivotchart_link; mod range_fill_formats; +mod recently_used; mod row_autofit; mod sheet_copy_spill; mod sheet_name_quoting; diff --git a/crates/engine/tests/it/recently_used.rs b/crates/engine/tests/it/recently_used.rs new file mode 100644 index 000000000..f40e3c1b0 --- /dev/null +++ b/crates/engine/tests/it/recently_used.rs @@ -0,0 +1,58 @@ +//! Formulas › Recently Used: the functions most recently entered in a formula, from any command +//! that enters one, kept in the preferences rather than read back from the journal. + +use gridcraft_engine::Session; +use serde_json::{Value as Json, json}; + +fn recent(s: &mut Session) -> Vec { + let list = s.execute("formulas.recentlyUsed", json!({})).unwrap(); + list.as_array().unwrap().iter().filter_map(Json::as_str).map(String::from).collect() +} + +#[test] +fn formulas_entered_by_any_command_lead_the_list() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap(); + s.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "PMT is just text"})).unwrap(); + assert_eq!(&recent(&mut s)[..4], ["FV", "IRR", "ROUND", "NPV"]); + // A command that fails enters nothing. + assert!(s.execute("cell.set", json!({"cell": "not a cell", "input": "=DB(1,2,3,4)"})).is_err()); + assert_ne!(recent(&mut s)[0], "DB"); +} + +#[test] +fn formulas_typed_with_a_plus_or_minus_count() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "+ROUND(1.5,0)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "-SLN(100,10,5)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "-5"})).unwrap(); + assert_eq!(&recent(&mut s)[..2], ["SLN", "ROUND"]); +} + +#[test] +fn the_list_does_not_depend_on_the_journal() { + // The journal drops its oldest entries past its caps (and is public); the list stays. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + s.journal.clear(); + assert_eq!(recent(&mut s)[0], "NPV"); + s.execute("cell.set", json!({"cell": "A2", "input": "=SLN(100,10,5)"})).unwrap(); + assert_eq!(&recent(&mut s)[..2], ["SLN", "NPV"]); +} + +#[test] +fn the_list_is_kept_with_the_preferences() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SYD(100,10,5,1)"})).unwrap(); + let saved = serde_json::to_value(&s.prefs).unwrap(); + let mut next = Session::new(); + next.new_workbook(); + next.prefs = serde_json::from_value(saved).unwrap(); + assert_eq!(recent(&mut next)[0], "SYD"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index df166e229..6eccc48eb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -856,7 +856,7 @@ impl Dialog { "insertFunction" => { // A Function Library menu opens it on its own category. let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string(); - Dialog::custom("insertFunction", "Formula Builder", json!({"category": category})) + Dialog::custom("insertFunction", "Insert Function", json!({"category": category})) } "find" => { let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"})); diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index 2f922d644..a77de272b 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -1,33 +1,87 @@ use super::show::ok_cancel; -use super::{Dialog, Json, SheetApp, vec2}; +use super::{Dialog, Json, SheetApp, json, vec2}; -/// Insert Function: searches names (as the formula language spells them) and descriptions, shows -/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). +/// Insert Function: search names (as the formula language spells them) and descriptions, or pick +/// a category (Most Recently Used, All or one category), then a function; each hit shows its +/// category and description. OK starts editing `=NAME(` (or appends to the formula). pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + use crate::function_library::{self as lib, ALL, MOST_RECENTLY_USED}; let language = app.ui.language; ui.horizontal(|ui| { ui.label(language.tr("Search:")); let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); r.request_focus(); }); + // A new search (typed, or set through the control channel) looks through every function, as + // Excel's does. + if d.values.get("searched").and_then(Json::as_str) != Some(d.search.as_str()) { + d.list_index = 0; + if !d.search.trim().is_empty() { + d.values.insert("category".into(), json!(ALL)); + } + d.values.insert("searched".into(), json!(d.search)); + } + // The category may be given by label or by engine id (a Function Library menu, `ui.dialog.set`). + let saved = d.values.get("category").and_then(Json::as_str).unwrap_or(MOST_RECENTLY_USED).to_string(); + let mut category = if saved.eq_ignore_ascii_case(ALL) { + ALL.to_string() + } else { + lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string() + }; + ui.horizontal(|ui| { + let label = ui.label(language.tr("Or select a category:")); + craft_ui::ComboBox::from_id_salt("insertFunctionCategory") + .width(220.0) + .selected_text(language.tr(&category)) + .show_ui(ui, |ui| { + for c in [MOST_RECENTLY_USED, ALL].into_iter().chain(lib::CATEGORIES.iter().map(|(_, l)| *l)) { + ui.selectable_value(&mut category, c.to_string(), language.tr(c)); + } + }) + .response + .labelled_by(label.id); + }); + if category != saved { + d.list_index = 0; + } + d.values.insert("category".into(), json!(category)); let locale = language.formula_locale(); - let list = gridcraft_engine::cmd::formulas::function_list(); // The names and descriptions searched are the ones shown, so in the interface language. - let q = d.search.to_lowercase(); - let hits: Vec<&Json> = list - .iter() - .filter(|f| { - q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) - }) - .take(300) - .collect(); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, f) in hits.iter().enumerate() { - let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let category = language.tr(crate::function_library::category_label(f["category"].as_str().unwrap_or(""))); - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); + let q = d.search.trim().to_lowercase(); + let matches = |f: &lib::FnEntry| { + q.is_empty() + || locale.function_name(&f.name).to_lowercase().contains(&q) + || language.function_description(&f.description).unwrap_or(&f.description).to_lowercase().contains(&q) + }; + let hits: Vec<&lib::FnEntry> = if category == MOST_RECENTLY_USED { + lib::recent(app).iter().filter_map(|n| lib::find(n)).filter(|f| matches(f)).collect() + } else { + let id = lib::category_id(&category); + // The catalog is fixed (some 500 functions), so All lists every one. + lib::all().iter().filter(|f| id.is_none_or(|id| f.category == id) && matches(f)).collect() + }; + let t = crate::theme::Tokens::get(ui.ctx()); + let font = egui::TextStyle::Body.resolve(ui.style()); + let rows: Vec<(&str, &str)> = hits.iter().map(|f| (locale.function_name(&f.name), language.tr(lib::category_label(&f.category)))).collect(); + // The category column starts after the widest name shown, so a long name in any formula + // language or font never runs into it. + let name_w = rows.iter().map(|(n, _)| ui.painter().layout_no_wrap(n.to_string(), font.clone(), t.text).size().x).fold(0.0_f32, f32::max); + egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, false]).show(ui, |ui| { + if rows.is_empty() { + ui.label(egui::RichText::new(language.tr("No functions match.")).italics()); + } + let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0; + for (i, (name, category)) in rows.iter().enumerate() { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width().max(name_w + 120.0), row_h), egui::Sense::click()); + if i == d.list_index { + ui.painter().rect_filled(rect, 3.0, ui.visuals().selection.bg_fill); + } else if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let color = if i == d.list_index { ui.visuals().selection.stroke.color } else { t.text }; + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, *name, font.clone(), color); + ui.painter().text(rect.left_center() + vec2(6.0 + name_w + 16.0, 0.0), egui::Align2::LEFT_CENTER, *category, font.clone(), t.text_dim); + r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == d.list_index, format!("{name} ({category})"))); if r.clicked() { d.list_index = i; } @@ -38,15 +92,19 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia } }); if let Some(f) = hits.get(d.list_index) { - let name = f["name"].as_str().unwrap_or(""); ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); + ui.label(egui::RichText::new(crate::formula_locale::signature(&f.name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(&f.name, locale, language).unwrap_or_default()); } let mut open = true; ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { - let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); + // The function counts as recently used once the formula is entered (the engine notes it). + if *confirm && let Some(f) = hits.get(d.list_index) { + let n = app + .session + .active() + .map(|doc| crate::formula_locale::completion_name(&f.name, locale, &doc.wb, doc.wb.active_sheet())) + .unwrap_or(&f.name); let cur = app.editor.as_ref().map(|e| e.text.clone()); let text = match cur { Some(t) if t.starts_with('=') => format!("{t}{n}("), diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 165d94cd8..cbc043ece 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -577,7 +577,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Επεξεργασία ονόματος", "New Name" => "Νέο όνομα", "Format Cells" => "Μορφοποίηση κελιών", - "Formula Builder" => "Δημιουργός τύπων", "Find and Replace" => "Εύρεση και αντικατάσταση", "Go To" => "Μετάβαση", "Insert Calculated Field" => "Εισαγωγή υπολογιζόμενου πεδίου", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 51d1db6c7..599b9c2a5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -453,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Modifica nome", "New Name" => "Nuovo nome", "Format Cells" => "Formato celle", - "Formula Builder" => "Generatore di formule", "Find and Replace" => "Trova e sostituisci", "Go To" => "Vai a", "Insert Calculated Field" => "Inserisci campo calcolato", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index b7cb6c95b..65015dfe0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -475,7 +475,6 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Edit Name", "名前の編集"), ("New Name", "新しい名前"), ("Format Cells", "セルの書式設定"), - ("Formula Builder", "数式ビルダー"), ("Find and Replace", "検索と置換"), ("Go To", "ジャンプ"), ("Insert Calculated Field", "計算のフィールドの挿入"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 3b58b0d20..ae4639760 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -447,7 +447,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Edytowanie nazwy", "New Name" => "Nowa nazwa", "Format Cells" => "Formatowanie komórek", - "Formula Builder" => "Konstruktor formuł", "Find and Replace" => "Znajdowanie i zamienianie", "Go To" => "Przechodzenie do", "Insert Calculated Field" => "Wstawianie pola obliczeniowego", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 0107235cf..32b38a953 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -469,7 +469,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Editar Nome", "New Name" => "Novo Nome", "Format Cells" => "Formatar Células", - "Formula Builder" => "Construtor de Fórmulas", "Find and Replace" => "Localizar e Substituir", "Go To" => "Ir para", "Insert Calculated Field" => "Inserir Campo Calculado", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index ade378b9d..f92555df2 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -453,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Editare nume", "New Name" => "Nume nou", "Format Cells" => "Formatare celule", - "Formula Builder" => "Generator de formule", "Find and Replace" => "Găsire și înlocuire", "Go To" => "Salt la", "Insert Calculated Field" => "Inserare câmp calculat", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index a17d4da56..699a22288 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -451,7 +451,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Edit Name", "New Name" => "New Name", "Format Cells" => "Format Cells", - "Formula Builder" => "Formula Builder", "Find and Replace" => "Find and Replace", "Go To" => "Go To", "Insert Calculated Field" => "Insert Calculated Field", diff --git a/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs index 917f36642..3d8a37d5e 100644 --- a/crates/ui-egui/tests/it/function_library.rs +++ b/crates/ui-egui/tests/it/function_library.rs @@ -1,5 +1,6 @@ //! Formulas › Function Library: the labelled buttons, their category menus, the More Functions -//! cascade, the AutoSum big split button and the Recently Used keytip. +//! cascade, the AutoSum big split button, the Recently Used keytip and list, and the Insert Function +//! dialog's categories. use egui::os::OperatingSystem; use egui::{Event, Key, Modifiers, vec2}; @@ -169,3 +170,104 @@ fn the_recently_used_keytip_opens_its_menu() { pick(&mut h, "AVERAGE"); assert_eq!(editor_text(&h).as_deref(), Some("=AVERAGE(")); } + +#[test] +fn recently_used_lists_the_engines_list_newest_first() { + let mut h = formulas_tab(1440.0); + // The ribbon and the control channel go through app.run; MCP and scripts through the session. + // The engine keeps the list (see the engine's recently_used tests); the menu shows it. + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap(); + h.state_mut().session.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap(); + h.run_steps(2); + assert_eq!(&h.state().session.prefs.recent_functions[..4], ["FV", "IRR", "ROUND", "NPV"]); + click(&mut h, "Recently Used"); + let top = |h: &Harness, name: &str| h.get_by_label(name).rect().top(); + assert!(top(&h, "FV") < top(&h, "IRR") && top(&h, "IRR") < top(&h, "ROUND") && top(&h, "ROUND") < top(&h, "NPV")); +} + +/// Opens the Insert Function category list (the dialog's only combo box). +fn open_categories(h: &mut Harness) { + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(3); +} + +/// Picks a category from the open list (the ribbon has buttons with the same names). +fn choose(h: &mut Harness, category: &str) { + h.get_all_by_label(category).last().expect("the category is listed").click(); + h.run_steps(3); +} + +fn row(name: &str, category: &str) -> String { + format!("{name} ({category})") +} + +#[test] +fn insert_function_has_its_title_and_a_working_category_list() { + let mut h = formulas_tab(1440.0); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + h.run_steps(2); + click(&mut h, "Insert Function"); + let d = h.state().dialog.as_ref().expect("Insert Function opens"); + assert_eq!(d.title, "Insert Function"); + // Most Recently Used comes first, newest first. + let npv = h.get_by_label(&row("NPV", "Financial")).rect(); + assert!(npv.top() < h.get_by_label(&row("SUM", "Math & Trig")).rect().top()); + assert!(h.query_by_label(&row("PMT", "Financial")).is_some(), "the default list follows"); + // Pick a category from the list. + open_categories(&mut h); + choose(&mut h, "Date & Time"); + assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some()); + assert!(h.query_by_label(&row("NPV", "Financial")).is_none()); + // All lists everything; a category given by its engine id works too (control channel). + open_categories(&mut h); + choose(&mut h, "All"); + assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some() && h.query_by_label(&row("NPV", "Financial")).is_some()); + h.state_mut().dialog.as_mut().unwrap().values.insert("category".into(), json!("MathTrig")); + h.run_steps(2); + assert!(h.query_by_label(&row("ROUND", "Math & Trig")).is_some() && h.query_by_label(&row("TODAY", "Date & Time")).is_none()); + // A search looks through every category. + h.state_mut().dialog.as_mut().unwrap().search = "lookup".into(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("category"), Some(&json!("All"))); + assert!(h.query_by_label(&row("XLOOKUP", "Lookup & Reference")).is_some()); + // OK starts the formula. The function counts as recently used once the formula is entered, + // not before (the edit may still be cancelled, or there may be no cell to edit). + pick(&mut h, &row("XLOOKUP", "Lookup & Reference")); + click(&mut h, " OK "); + assert_eq!(editor_text(&h).as_deref(), Some("=XLOOKUP(")); + assert_ne!(h.state().session.prefs.recent_functions[0], "XLOOKUP"); +} + +/// The text shapes the last frame painted, with their left edge and width. +fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> { + let mut out = Vec::new(); + for clipped in &h.output().shapes { + if let egui::Shape::Text(t) = &clipped.shape { + out.push((t.galley.text().to_string(), t.pos.x, t.galley.size().x)); + } + } + out +} + +#[test] +fn insert_function_keeps_the_category_column_clear_of_long_names() { + let mut h = formulas_tab(1440.0); + // A wide interface font (as on some Windows and Linux desktops, or a long formula language) + // makes every name wider than the dialog was laid out for. + h.ctx.all_styles_mut(|s| { + s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(20.0)); + }); + click(&mut h, "Insert Function"); + // Some of the longest function names in the catalog, and a short one, in one list (the + // signature under the list starts with a name too, and is not a row). + h.state_mut().dialog.as_mut().unwrap().search = "forecast".into(); + h.run_steps(3); + let shapes = painted_text(&h); + let names: Vec<&(String, f32, f32)> = shapes.iter().filter(|(t, _, _)| t.starts_with("FORECAST") && !t.contains('(')).collect(); + let column = shapes.iter().filter(|(t, _, _)| t == "Statistical").map(|(_, x, _)| *x).fold(f32::INFINITY, f32::min); + assert!(names.iter().any(|(t, _, _)| t == "FORECAST.ETS.SEASONALITY"), "the long name is listed: {shapes:?}"); + for (name, x, w) in names { + assert!(x + w < column, "{name} ends at {} and the category column starts at {column}", x + w); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 67bf839f8..eb91fbd9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -71,7 +71,8 @@ drawing its own: - **Function Library**: `function_library.rs` holds the function categories (engine id and label, in the Insert Function dialog's order), the function list built once from `cmd::formulas::function_list`, and the menus of Formulas › Function Library, including the - AutoSum menu that Home › Editing shares. + AutoSum menu that Home › Editing shares. Recently Used and the Insert Function dialog's Most + Recently Used category list what `formulas.recentlyUsed` returns. - **Code editor**: `widgets::code_editor(ui, id, &mut text, Lang, max_lines)` (`code_editor.rs`) edits code in the monospace font with `Tokens::syntax` colours: Python (keywords, strings including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts, @@ -108,6 +109,14 @@ Actions made of many commands (macros, a debugger that runs one step per call) u a recording leaves it out, so playing the recording back never stops the run. The history limit is applied when the outermost command finishes; until then the steps made inside it are bounded on their own. +- **Recently used functions** (`recent.rs`). `Prefs::recent_functions` holds the ten functions + most recently entered in a formula, newest first. `Session::execute` updates it for every + `cell.*` and `range.*` command it journals, from any source (interface, control channel, MCP, + script). It reads at most 2,000 of the formulas a command enters, starting from the last one + (the most recent), so a large paste costs no more than that; `formulas.recentlyUsed` returns + it. It is kept with the other preferences (`prefs.json`, the browser's storage on the + web) and read leniently, so a malformed value loses only this list. It does not read the + journal back, so the journal's trimming does not affect it. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, nor is a menu-style call that only opened a @@ -165,6 +174,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Recently used functions (`recent.rs`, `Prefs::recent_functions`) behind `formulas.recentlyUsed` | | 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) | | 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7cee60f60..86837305b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Insert Function dialog is named as in Excel; no score change; earlier: automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -314,7 +314,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land galleries (cell styles, table styles, colour palettes, conditional formatting menus), contextual Table/Chart Design tabs, formula bar with Name Box, virtualised grid (smooth scrolling, zoom, frozen panes, text overflow), sheet tabs (rename, reorder, colour, hide), - status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Formula Builder, Find and + status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Insert Function, Find and Replace, Sort, Name Manager, Data Validation, Paste Special…), command palette, dark mode. - Agents: every action is an engine command; JSON control channel in the desktop app (pointer, keyboard, dialogs, screenshots); MCP server (headless or bridged); CLI (`info`, `convert`, @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | | 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 630743d03..eaeb619ec 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-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **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 @@ -18,7 +18,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928); large split buttons (Formulas › AutoSum); arrows at the card's edges scroll a tab wider than the window | done; missing contextual tabs for pictures, shapes, sparklines, slicers; groups do not collapse when narrow | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | -| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; Recently Used lists what `formulas.recentlyUsed` returns | +| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | @@ -157,7 +157,8 @@ Insert Hyperlink loads an existing cell link's address, display text and ScreenT (#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel keeps the original link and an edit can be undone in one step. -Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, +Format Cells, Insert Function (search, Most Recently Used, All and each category), Find and +Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step @@ -195,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |