Engine: Recently Used follows the formulas entered; Insert Function category list

formulas.recentlyUsed scanned the session journal for cell.set inputs.
It missed formulas entered by range.* commands, lost them when the
journal dropped its oldest entries (a large file open or a long script
is enough), and started empty in every session. The Function Library's
Recently Used menu and the Insert Function dialog list what it returns.

- Prefs::recent_functions (a new engine module, recent.rs) 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 (the interface, the control channel, MCP or
  a script). It reads at most 2,000 of the formulas a command enters,
  starting from the last one; formulas longer than Excel's limit are
  skipped. formulas.recentlyUsed
  returns it. It never reads the journal back, so the journal's caps do
  not affect it.
- The list is kept with the other engine preferences (prefs.json, the
  browser's storage on the web), so it survives a restart as Excel's
  does. It is read leniently: a malformed value loses only this list. A
  fresh install starts from common functions.

The Insert Function dialog is titled "Insert Function", as in Excel (it
said "Formula Builder"), and gains a working category list: Most
Recently Used, All, then each category in Excel's order. A category may
be given by label or by engine id, so a Function Library menu or the
control channel (ui.dialog.set) opens it on one, and a new search
switches to All. The dialog keeps its translated labels and its search
in the interface language. Each row shows the function's name and its
category in two columns; the category column starts after the widest
name shown, so a long name in any font or formula language stays clear
of it. A function counts as recently used once its formula is entered,
not when OK is pressed. The unused "Formula Builder" translations are
removed.

Tests: engine unit tests for reading functions from formulas (strings,
unknown names, over-long and malformed formulas), the formulas a
command enters (newest first, within the budget), the ten-name list
and the lenient read of prefs.json; tests/it/recently_used.rs for
cell.set and range.setValues from the session, a failed command, a
cleared journal and a save and reload of the preferences. UI tests for
the menu's order and the dialog's title, categories, engine-id
category, search and OK, and for the category column with a wide font.
This commit is contained in:
prxnvt
2026-10-31 04:00:56 -04:00
parent 6aa5a78828
commit 474472f999
22 changed files with 496 additions and 67 deletions
+3 -1
View File
@@ -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 |
+1 -1
View File
@@ -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};
+11 -6
View File
@@ -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)?;
+1 -1
View File
@@ -310,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
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"}))),
+3 -18
View File
@@ -122,25 +122,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// 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<Json> {
let mut seen: Vec<String> = 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<Json> {
+7
View File
@@ -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<String>,
}
/// 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(&params));
recent::note(&mut self.prefs.recent_functions, id, &params);
}
}
// Inside an undo group the group owns the step. A command that ended a group open before
+205
View File
@@ -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<String> {
["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<HashSet<String>> = 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<String> {
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<String> = 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<String>, names: &[String]) {
let mut next: Vec<String> = 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<String>, id: &str, params: &Json) {
let mut names: Vec<String> = 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<Vec<String>, 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, &params, SCAN_BUDGET).into_iter().map(String::from).collect::<Vec<_>>();
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<Json> = (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<String> =
["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::<crate::Prefs>(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<Json> = ["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");
}
}
+1
View File
@@ -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;
+58
View File
@@ -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<String> {
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");
}
+1 -1
View File
@@ -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"}));
+82 -24
View File
@@ -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}("),
-1
View File
@@ -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" => "Εισαγωγή υπολογιζόμενου πεδίου",
-1
View File
@@ -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",
-1
View File
@@ -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", "計算のフィールドの挿入"),
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
+103 -1
View File
@@ -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);
}
}
+12 -2
View File
@@ -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_<n>_<k>` |
| 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) |
+3 -2
View File
@@ -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 |
+5 -3
View File
@@ -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 |