Merge pull request #467 from prxnvt/pr/function-library

UI: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories
This commit is contained in:
aiongg
2026-11-01 13:44:57 -05:00
committed by GitHub
47 changed files with 1251 additions and 220 deletions
+5 -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-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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.
@@ -138,6 +138,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
| 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 |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
@@ -250,6 +252,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|---|---|---|
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
| 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 |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); 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(),
}
}
}
@@ -801,6 +807,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");
}
+7 -1
View File
@@ -853,7 +853,13 @@ impl Dialog {
}
d
}
"insertFunction" => Dialog::custom("insertFunction", "Formula Builder", json!({"category": "Most Recently Used"})),
"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();
let mut d = Dialog::custom("insertFunction", "Insert Function", json!({"category": category}));
d.focus_first_field = true;
d
}
"find" => {
let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"}));
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
+94 -38
View File
@@ -1,33 +1,97 @@
use super::show::ok_cancel;
use super::{Dialog, Json, SheetApp, vec2};
use super::show::{ok_cancel, take_first_focus};
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();
// Once only, so Tab can move on to the category list.
take_first_focus(d, &r);
});
// 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()
};
// The category list is the dialog's only menu, so a menu open as the frame starts is its own
// (Enter on a row picks it and closes the menu in this frame).
let menu_was_open = egui::Popup::is_any_open(ui.ctx());
let picker = 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)
});
// Enter on the category list or in its menu opens it or picks a category, as Space does
// (egui clicks a focused control on either key); it does not also confirm the dialog.
let picker = picker.inner;
if menu_was_open || picker.has_focus() || craft_ui::ComboBox::is_open(ui.ctx(), picker.id) {
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
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(function_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 +102,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}("),
@@ -58,15 +126,3 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia
*confirm = true;
}
}
/// Excel's name for a function category, given the catalog's id (`MathTrig`).
fn function_category_label(id: &str) -> &str {
match id {
"DateTime" => "Date & Time",
"Lookup" => "Lookup & Reference",
"MathTrig" => "Math & Trig",
"AddIn" => "Add-in and Automation",
// Financial, Logical, Text, Statistical, Engineering, Database, Information, …
other => other,
}
}
+1 -1
View File
@@ -464,7 +464,7 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
/// Enter sets the width, as in Excel.
/// Keeps asking until the field really has focus: a new window's first frame is an invisible
/// sizing pass where a focus request is ignored.
fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
pub(super) fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
if !d.focus_first_field {
return;
}
+191
View File
@@ -0,0 +1,191 @@
//! The Formulas tab's Function Library: the function categories, their menus, the AutoSum menu
//! (shared with Home › Editing), and the Recently Used menu, which lists what the engine's
//! `formulas.recentlyUsed` returns.
use std::sync::OnceLock;
use egui::{Ui, vec2};
use serde_json::json;
use crate::SheetApp;
/// The Insert Function category that lists [`recent`].
pub const MOST_RECENTLY_USED: &str = "Most Recently Used";
/// The Insert Function category that lists every function.
pub const ALL: &str = "All";
/// The function categories in the order the Insert Function dialog lists them: (the engine's
/// category id, the label people see).
pub const CATEGORIES: [(&str, &str); 14] = [
("Financial", "Financial"),
("DateTime", "Date & Time"),
("MathTrig", "Math & Trig"),
("Statistical", "Statistical"),
("Lookup", "Lookup & Reference"),
("Database", "Database"),
("Text", "Text"),
("Logical", "Logical"),
("Information", "Information"),
("Engineering", "Engineering"),
("Cube", "Cube"),
("Compatibility", "Compatibility"),
("Web", "Web"),
("AddIn", "Add-in and Automation"),
];
/// The categories under More Functions, each a cascading submenu.
pub const MORE_FUNCTIONS: [&str; 6] = ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"];
/// The label of an engine category id (the id itself if it is unknown).
pub fn category_label(id: &str) -> &str {
CATEGORIES.iter().find(|(c, _)| *c == id).map(|(_, l)| *l).unwrap_or(id)
}
/// The engine category id for a category given by id or label, in any case.
pub fn category_id(name: &str) -> Option<&'static str> {
CATEGORIES.iter().find(|(c, l)| c.eq_ignore_ascii_case(name) || l.eq_ignore_ascii_case(name)).map(|(c, _)| *c)
}
/// One function of the library.
#[derive(Clone, Debug)]
pub struct FnEntry {
pub name: String,
pub category: String,
pub description: String,
}
/// Every function, sorted by name (built once; the list is static).
pub fn all() -> &'static [FnEntry] {
static LIST: OnceLock<Vec<FnEntry>> = OnceLock::new();
LIST.get_or_init(|| {
let mut list: Vec<FnEntry> = gridcraft_engine::cmd::formulas::function_list()
.iter()
.filter_map(|f| {
Some(FnEntry {
name: f["name"].as_str()?.to_string(),
category: f["category"].as_str().unwrap_or("").to_string(),
description: f["description"].as_str().unwrap_or("").to_string(),
})
})
.collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
list.dedup_by(|a, b| a.name == b.name);
list
})
}
/// The function named `name` (upper case), if the library knows it.
pub fn find(name: &str) -> Option<&'static FnEntry> {
let list = all();
list.binary_search_by(|f| f.name.as_str().cmp(name)).ok().and_then(|i| list.get(i))
}
/// The functions of one engine category, in name order.
pub fn in_category(id: &str) -> impl Iterator<Item = &'static FnEntry> + '_ {
all().iter().filter(move |f| f.category == id)
}
/// The name a function shows under in the interface language.
fn display_name<'a>(app: &SheetApp, name: &'a str) -> &'a str {
let locale = app.ui.language.formula_locale();
app.session.active().map(|d| crate::formula_locale::completion_name(name, locale, &d.wb, d.wb.active_sheet())).unwrap_or(name)
}
/// One menu row per function, with its description on hover; a click starts the formula.
fn function_items<'a>(app: &mut SheetApp, ui: &mut Ui, names: impl Iterator<Item = &'a str>) {
let language = app.ui.language;
let locale = language.formula_locale();
ui.set_max_height(420.0);
egui::ScrollArea::vertical().max_height(380.0).show(ui, |ui| {
for n in names {
let Some(entry) = find(n) else { continue };
let name = display_name(app, &entry.name);
let r = ui.add(egui::Button::new(name).frame(false).min_size(vec2(180.0, 20.0)));
// The description is looked up only while the row is hovered.
let r = r.on_hover_ui(|ui| {
ui.label(crate::formula_locale::description(&entry.name, locale, language).unwrap_or_default());
});
if r.clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": entry.name}));
ui.close();
}
}
});
}
/// The "Insert Function…" row that ends every category menu; it opens the dialog on `category`.
fn insert_function_item(app: &mut SheetApp, ui: &mut Ui, category: &str) {
ui.separator();
let label = app.ui.language.tr("Insert Function…");
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 22.0))).clicked() {
app.open_dialog("insertFunction", json!({"category": category}));
ui.close();
}
}
/// The menu of one category: its functions, then Insert Function….
pub fn category_menu(app: &mut SheetApp, ui: &mut Ui, id: &str) {
let names: Vec<&str> = in_category(id).map(|f| f.name.as_str()).collect();
function_items(app, ui, names.into_iter());
insert_function_item(app, ui, category_label(id));
}
/// The functions `formulas.recentlyUsed` lists, newest first (none without a workbook).
pub fn recent(app: &mut SheetApp) -> Vec<String> {
let list = app.session.execute("formulas.recentlyUsed", json!({})).unwrap_or_default();
list.as_array().map(|a| a.iter().filter_map(|n| n.as_str().map(String::from)).collect()).unwrap_or_default()
}
/// Recently Used: the most recent functions, newest first, then Insert Function….
pub fn recent_menu(app: &mut SheetApp, ui: &mut Ui) {
let recent = recent(app);
function_items(app, ui, recent.iter().map(String::as_str));
insert_function_item(app, ui, MOST_RECENTLY_USED);
}
/// More Functions: a cascading submenu per remaining category.
pub fn more_menu(app: &mut SheetApp, ui: &mut Ui) {
for id in MORE_FUNCTIONS {
let label = app.ui.language.tr(category_label(id)).to_string();
ui.menu_button(label, |ui| category_menu(app, ui, id));
}
}
/// The AutoSum menu, shared by Home › Editing and Formulas › Function Library: a function, or
/// the Insert Function dialog.
pub fn auto_sum_menu(app: &mut SheetApp, ui: &mut Ui) {
for (label, f) in [("Sum", "SUM"), ("Average", "AVERAGE"), ("Count Numbers", "COUNT"), ("Max", "MAX"), ("Min", "MIN")] {
let label = app.ui.language.tr(label);
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(160.0, 22.0))).clicked() {
app.run_or_alert("formulas.autoSum", json!({"function": f}));
ui.close();
}
}
ui.separator();
let label = app.ui.language.tr("More Functions…");
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(160.0, 22.0))).clicked() {
app.open_dialog("insertFunction", json!({}));
ui.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn categories_cover_every_function() {
for f in all() {
assert!(category_id(&f.category).is_some(), "{} is in an unknown category {}", f.name, f.category);
}
assert_eq!(category_id("date & time"), Some("DateTime"));
assert_eq!(category_id("Lookup"), Some("Lookup"));
assert_eq!(category_id("nope"), None);
for c in MORE_FUNCTIONS {
assert!(category_id(c).is_some());
}
assert!(all().windows(2).all(|w| w[0].name < w[1].name), "sorted by name, without duplicates");
assert_eq!(find("SUM").map(|f| f.category.as_str()), Some("MathTrig"));
assert!(find("NOT_A_FUNCTION").is_none());
}
}
+5 -4
View File
@@ -417,11 +417,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Dlouhé datum",
"More Number Formats…" => "Další formáty čísel…",
"Financial" => "Finanční",
"Recently Used" => "Naposledy použité",
"Logical" => "Logické",
"Date &\nTime" => "Datum a\nčas",
"Lookup &\nReference" => "Vyhledávání\na odkazy",
"Math &\nTrig" => "Matematika a\ntrigonometrie",
"More\nFunctions" => "Další\nfunkce",
"Date & Time" => "Datum a čas",
"Lookup & Reference" => "Vyhledávání a odkazy",
"Math & Trig" => "Matematika a trigonometrie",
"More Functions" => "Další funkce",
"Sheet Options|View" => "Zobrazit",
"Print" => "Tisknout",
"Print What" => "Co tisknout",
+5 -4
View File
@@ -420,11 +420,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Langes Datum",
"More Number Formats…" => "Weitere Zahlenformate…",
"Financial" => "Finanzmathematik",
"Recently Used" => "Zuletzt verwendet",
"Logical" => "Logisch",
"Date &\nTime" => "Datum &\nUhrzeit",
"Lookup &\nReference" => "Nachschlagen &\nVerweisen",
"Math &\nTrig" => "Mathematik &\nTrigonometrie",
"More\nFunctions" => "Weitere\nFunktionen",
"Date & Time" => "Datum & Uhrzeit",
"Lookup & Reference" => "Nachschlagen & Verweisen",
"Math & Trig" => "Mathematik & Trigonometrie",
"More Functions" => "Weitere Funktionen",
"Page Layout|Orientation" => "Seitenausrichtung",
"Display theme" => "Anzeigedesign",
"System" => "System",
+1 -5
View File
@@ -389,10 +389,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Περισσότερες μορφές αριθμών…",
"Financial" => "Οικονομικές",
"Logical" => "Λογικές",
"Date &\nTime" => "Ημερομηνία και\nώρα",
"Lookup &\nReference" => "Αναζήτηση και\nαναφορά",
"Math &\nTrig" => "Μαθηματικά και\nτριγωνομετρία",
"More\nFunctions" => "Περισσότερες\nσυναρτήσεις",
"More Functions" => "Περισσότερες συναρτήσεις",
"Page Layout|Orientation" => "Προσανατολισμός σελίδας",
"Display theme" => "Θέμα εμφάνισης",
"System" => "Σύστημα",
@@ -580,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" => "Εισαγωγή υπολογιζόμενου πεδίου",
+5 -4
View File
@@ -417,11 +417,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Fecha larga",
"More Number Formats…" => "Más formatos de número…",
"Financial" => "Financieras",
"Recently Used" => "Usadas recientemente",
"Logical" => "Lógicas",
"Date &\nTime" => "Fecha\ny hora",
"Lookup &\nReference" => "Búsqueda y\nreferencia",
"Math &\nTrig" => "Matemáticas y\ntrigonométricas",
"More\nFunctions" => "Más\nfunciones",
"Date & Time" => "Fecha y hora",
"Lookup & Reference" => "Búsqueda y referencia",
"Math & Trig" => "Matemáticas y trigonométricas",
"More Functions" => "Más funciones",
"Page Layout|Orientation" => "Orientación",
"Display theme" => "Tema de la interfaz",
"System" => "Sistema",
+5 -4
View File
@@ -308,11 +308,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Date longue",
"More Number Formats…" => "Autres formats numériques…",
"Financial" => "Financières",
"Recently Used" => "Récemment utilisées",
"Logical" => "Logiques",
"Date &\nTime" => "Date et\nheure",
"Lookup &\nReference" => "Recherche et\nréférence",
"Math &\nTrig" => "Maths et\ntrigonométrie",
"More\nFunctions" => "Autres\nfonctions",
"Date & Time" => "Date et heure",
"Lookup & Reference" => "Recherche et référence",
"Math & Trig" => "Maths et trigonométrie",
"More Functions" => "Autres fonctions",
"Sheet Options|View" => "Affichage",
"Print" => "Impression",
"Print What" => "Quoi imprimer",
+1 -5
View File
@@ -291,10 +291,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Altri formati numero…",
"Financial" => "Finanziarie",
"Logical" => "Logiche",
"Date &\nTime" => "Data e\nora",
"Lookup &\nReference" => "Ricerca e\nriferimento",
"Math &\nTrig" => "Matematiche e\ntrigonometriche",
"More\nFunctions" => "Altre\nfunzioni",
"More Functions" => "Altre funzioni",
"Page Layout|Orientation" => "Orientamento",
"Display theme" => "Tema di visualizzazione",
"Animations" => "Animazioni",
@@ -456,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",
+2 -5
View File
@@ -304,10 +304,8 @@ pub(super) const CORE: &[(&str, &str)] = &[
("More Number Formats…", "その他の表示形式…"),
("Financial", "財務"),
("Logical", "論理"),
("Date &\nTime", "日付/時刻"),
("Lookup &\nReference", "検索/行列"),
("Math &\nTrig", "数学/三角"),
("More\nFunctions", "その他の関数"),
("Recently Used", "最近使用した関数"),
("More Functions", "その他の関数"),
("Sheet Options|View", "画面"),
("Print", "印刷"),
("Print What", "印刷する内容"),
@@ -477,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", "計算のフィールドの挿入"),
+5 -4
View File
@@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "자세한 날짜",
"More Number Formats…" => "숫자 서식 더 보기…",
"Financial" => "재무",
"Recently Used" => "최근 사용 항목",
"Logical" => "논리",
"Date &\nTime" => "날짜 및\n시간",
"Lookup &\nReference" => "조회 및\n참조",
"Math &\nTrig" => "수학 및\n삼각",
"More\nFunctions" => "기타\n함수",
"Date & Time" => "날짜 및 시간",
"Lookup & Reference" => "조회 및 참조",
"Math & Trig" => "수학 및 삼각",
"More Functions" => "기타 함수",
"Page Layout|Orientation" => "용지 방향",
"Display theme" => "표시 테마",
"Animations" => "애니메이션",
+4 -4
View File
@@ -300,10 +300,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Meer getalnotaties…",
"Financial" => "Financieel",
"Logical" => "Logisch",
"Date &\nTime" => "Datum en\ntijd",
"Lookup &\nReference" => "Zoeken en\nverwijzen",
"Math &\nTrig" => "Wiskunde en\ntrigonometrie",
"More\nFunctions" => "Meer\nfuncties",
"Date & Time" => "Datum en tijd",
"Lookup & Reference" => "Zoeken en verwijzen",
"Math & Trig" => "Wiskunde en trigonometrie",
"More Functions" => "Meer functies",
"Sheet Options|View" => "Weergeven",
"Print" => "Afdrukken",
"Print What" => "Wat afdrukken",
+1 -5
View File
@@ -287,10 +287,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Więcej formatów liczb…",
"Financial" => "Finansowe",
"Logical" => "Logiczne",
"Date &\nTime" => "Data i\ngodzina",
"Lookup &\nReference" => "Wyszukiwanie\ni odwołania",
"Math &\nTrig" => "Matematyczne i\ntrygonometryczne",
"More\nFunctions" => "Więcej\nfunkcji",
"More Functions" => "Więcej funkcji",
"Page Layout|Orientation" => "Orientacja",
"Display theme" => "Motyw wyświetlania",
"Animations" => "Animacje",
@@ -450,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",
+2 -5
View File
@@ -291,11 +291,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Data Por Extenso",
"More Number Formats…" => "Mais Formatos de Número…",
"Financial" => "Financeira",
"Recently Used" => "Usadas Recentemente",
"Logical" => "Lógica",
"Date &\nTime" => "Data e\nHora",
"Lookup &\nReference" => "Pesquisa e\nReferência",
"Math &\nTrig" => "Matemática e\nTrigonometria",
"More\nFunctions" => "Mais\nFunções",
"More Functions" => "Mais Funções",
"Page Layout|Orientation" => "Orientação",
"Display theme" => "Tema de exibição",
"Animations" => "Animações",
@@ -471,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 -5
View File
@@ -294,10 +294,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Mai multe formate de număr…",
"Financial" => "Financiare",
"Logical" => "Logice",
"Date &\nTime" => "Dată și\noră",
"Lookup &\nReference" => "Căutare și\nreferință",
"Math &\nTrig" => "Matematică și\ntrigonometrie",
"More\nFunctions" => "Mai multe\nfuncții",
"More Functions" => "Mai multe funcții",
"Page Layout|Orientation" => "Orientare",
"Display theme" => "Temă de afișare",
"Animations" => "Animații",
@@ -456,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",
+5 -4
View File
@@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Длинная дата",
"More Number Formats…" => "Другие числовые форматы…",
"Financial" => "Финансовые",
"Recently Used" => "Последние",
"Logical" => "Логические",
"Date &\nTime" => "Дата и\nвремя",
"Lookup &\nReference" => "Поиск и\nссылки",
"Math &\nTrig" => "Математические и\nтригонометрические",
"More\nFunctions" => "Другие\nфункции",
"Date & Time" => "Дата и время",
"Lookup & Reference" => "Поиск и ссылки",
"Math & Trig" => "Математические и тригонометрические",
"More Functions" => "Другие функции",
"Page Layout|Orientation" => "Ориентация",
"Display theme" => "Тема оформления",
"Animations" => "Анимация",
+4 -4
View File
@@ -299,10 +299,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Još formata brojeva…",
"Financial" => "Finansijsko",
"Logical" => "Logičko",
"Date &\nTime" => "Datum i\nvreme",
"Lookup &\nReference" => "Pretraga i\nreference",
"Math &\nTrig" => "Matematika i\ntrigonometrija",
"More\nFunctions" => "Još\nfunkcija",
"Date & Time" => "Datum i vreme",
"Lookup & Reference" => "Pretraga i reference",
"Math & Trig" => "Matematika i trigonometrija",
"More Functions" => "Još funkcija",
"Sheet Options|View" => "Prikaz",
"Print" => "Štampaj",
"Print What" => "Šta štampati",
+4 -4
View File
@@ -403,10 +403,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Diğer Sayı Biçimleri…",
"Financial" => "Finansal",
"Logical" => "Mantıksal",
"Date &\nTime" => "Tarih ve\nSaat",
"Lookup &\nReference" => "Arama ve\nBaşvuru",
"Math &\nTrig" => "Matematik ve\nTrigonometri",
"More\nFunctions" => "Diğer\nİşlevler",
"Date & Time" => "Tarih ve Saat",
"Lookup & Reference" => "Arama ve Başvuru",
"Math & Trig" => "Matematik ve Trigonometri",
"More Functions" => "Diğer İşlevler",
"Page Layout|Orientation" => "Sayfa Yönü",
"Display theme" => "Görüntü teması",
"System" => "Sistem",
+4 -8
View File
@@ -289,10 +289,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Інші числові формати…",
"Financial" => "Фінансові",
"Logical" => "Логічні",
"Date &\nTime" => "Дата і\nчас",
"Lookup &\nReference" => "Пошук і\nпосилання",
"Math &\nTrig" => "Математичні і\nтригонометричні",
"More\nFunctions" => "Інші\nфункції",
"More Functions" => "Інші функції",
"Page Layout|Orientation" => "Орієнтація",
"Display theme" => "Тема оформлення",
"Animations" => "Анімація",
@@ -454,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",
@@ -711,9 +707,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Hidden" => "Hidden",
"Search:" => "Search:",
"e.g. lookup, average, date" => "e.g. lookup, average, date",
"Date & Time" => "Date & Time",
"Lookup & Reference" => "Lookup & Reference",
"Math & Trig" => "Math & Trig",
"Date & Time" => "Дата і час",
"Lookup & Reference" => "Пошук і посилання",
"Math & Trig" => "Математичні і тригонометричні",
"Statistical" => "Statistical",
"Engineering" => "Engineering",
"Database" => "Database",
+4 -4
View File
@@ -303,10 +303,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Định dạng số khác…",
"Financial" => "Tài chính",
"Logical" => "Logic",
"Date &\nTime" => "Ngày &\nGiờ",
"Lookup &\nReference" => "Tra cứu &\nTham chiếu",
"Math &\nTrig" => "Toán &\nLượng giác",
"More\nFunctions" => "Hàm\nkhác",
"Date & Time" => "Ngày & Giờ",
"Lookup & Reference" => "Tra cứu & Tham chiếu",
"Math & Trig" => "Toán & Lượng giác",
"More Functions" => "Hàm khác",
"Sheet Options|View" => "Xem",
"Print" => "In",
"Print What" => "Nội dung in",
+5 -4
View File
@@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "长日期",
"More Number Formats…" => "更多数字格式…",
"Financial" => "财务",
"Recently Used" => "最近使用的函数",
"Logical" => "逻辑",
"Date &\nTime" => "日期和\n时间",
"Lookup &\nReference" => "查找和\n引用",
"Math &\nTrig" => "数学和\n三角函数",
"More\nFunctions" => "更多\n函数",
"Date & Time" => "日期和时间",
"Lookup & Reference" => "查找和引用",
"Math & Trig" => "数学和三角函数",
"More Functions" => "更多函数",
"Page Layout|Orientation" => "纸张方向",
"Display theme" => "显示主题",
"Animations" => "动画",
+89 -2
View File
@@ -8,6 +8,27 @@ use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};
use crate::theme::{Cat, Tokens};
/// The glyph on a Function Library book ([`Icon::FnBook`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FnGlyph {
/// Recently Used: a star.
Star,
/// Financial: a stack of coins.
Coins,
/// Logical: a question mark.
Question,
/// Text: a letter.
Letter,
/// Date & Time: a clock face.
Clock,
/// Lookup & Reference: a magnifier.
Magnifier,
/// Math & Trig: theta.
Theta,
/// More Functions: three dots.
Dots,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Icon {
Paste,
@@ -121,6 +142,9 @@ pub enum Icon {
SheetList,
Book,
Folder,
/// A book with a coloured spine and a category glyph (Formulas › Function Library). The
/// spine and glyph take the accent colour of [`paint_accent`].
FnBook(FnGlyph),
/// Header & Footer: a page with its top / bottom band marked.
Header,
Footer,
@@ -170,6 +194,10 @@ impl G<'_> {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(PathShape::line(v, PathStroke::new(w * self.s.clamp(1.0, 2.0), c)));
}
fn closed(&self, pts: &[(f32, f32)], c: Color32, w: f32) {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(PathShape::closed_line(v, PathStroke::new(w * self.s.clamp(1.0, 2.0), c)));
}
fn stroke(&self, pts: &[(f32, f32)], c: Color32) {
self.line(pts, c, 1.15);
}
@@ -208,17 +236,20 @@ impl G<'_> {
}
/// The colours an icon draws with besides its main colour: the paper inside documents and cells,
/// and the category palette for its coloured parts.
/// the category palette for its coloured parts, and the accent a button asked for.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Palette {
paper: Color32,
cat: [Color32; 8],
recording: Color32,
/// The accent colour: the coloured part of an icon that has one (a book's spine and glyph),
/// otherwise the icon's main colour. `None` draws the icon as it is.
accent: Option<Color32>,
}
impl Palette {
fn of(t: &Tokens) -> Palette {
Palette { paper: t.paper, cat: t.cat, recording: t.recording }
Palette { paper: t.paper, cat: t.cat, recording: t.recording, accent: None }
}
fn cat(&self, c: Cat) -> Color32 {
@@ -242,11 +273,23 @@ pub fn fallback_for_symbol(c: char) -> Option<Icon> {
}
}
/// Whether an icon has a part of its own for the accent colour (otherwise the accent recolours
/// the whole icon).
fn has_accent_part(icon: Icon) -> bool {
matches!(icon, Icon::FnBook(_))
}
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
}
/// Draws `icon` like [`paint`], with an accent colour: the icon's coloured part when it has one
/// (the spine and glyph of [`Icon::FnBook`]), otherwise the whole icon. `None` is [`paint`].
pub fn paint_accent(p: &Painter, rect: Rect, icon: Icon, c: Color32, accent: Option<Color32>) {
paint_with(p, rect, icon, c, &Palette { accent, ..Palette::of(&Tokens::get(p.ctx())) });
}
/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette.
pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
paint_with(p, rect, icon, c, &Palette::of(&Tokens::light()));
@@ -257,6 +300,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
let side = rect.width().min(rect.height());
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let g = G { p, r, s: side / 16.0 };
let c = match pal.accent {
Some(a) if !has_accent_part(icon) => a,
_ => c,
};
let light = c.gamma_multiply(0.45);
let paper = pal.paper;
let blue = pal.cat(Cat::Blue);
@@ -690,6 +737,46 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
Icon::Folder => {
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow);
}
Icon::FnBook(glyph) => {
let spine = pal.accent.unwrap_or(c);
g.rect(3.0, 1.5, 10.5, 13.0, Some(paper), Some(c));
g.rect(3.0, 1.5, 2.5, 13.0, Some(spine), None);
let (x, y) = (9.5, 7.8);
match glyph {
FnGlyph::Star => {
let pts: Vec<(f32, f32)> = (0..10)
.map(|i| {
let a = std::f32::consts::PI * (i as f32 / 5.0 - 0.5);
let rad = if i % 2 == 0 { 3.4 } else { 1.4 };
(x + rad * a.cos(), y + rad * a.sin())
})
.collect();
g.closed(&pts, spine, 1.0);
}
FnGlyph::Coins => {
for k in 0..3 {
let top = y + 1.6 - k as f32 * 2.3;
g.p.rect_filled(Rect::from_min_max(g.pt(x - 2.8, top), g.pt(x + 2.8, top + 1.7)), 0.9 * g.s, spine);
}
}
FnGlyph::Question => g.text(x, y, "?", 8.5, spine, (true, false)),
FnGlyph::Letter => g.text(x, y, "A", 8.0, spine, (true, false)),
FnGlyph::Clock => {
g.circle(x, y, 3.2, None, Some(spine));
g.stroke(&[(x, y - 2.0), (x, y), (x + 1.6, y + 0.9)], spine);
}
FnGlyph::Magnifier => {
g.circle(x - 0.6, y - 0.6, 2.3, None, Some(spine));
g.line(&[(x + 1.0, y + 1.0), (x + 3.0, y + 3.0)], spine, 1.5);
}
FnGlyph::Theta => g.text(x, y, "θ", 8.5, spine, (false, false)),
FnGlyph::Dots => {
for dx in [-2.4, 0.0, 2.4] {
g.circle(x + dx, y, 0.9, Some(spine), None);
}
}
}
}
Icon::Header | Icon::Footer => {
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
@@ -243,6 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference)
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
("MR", Entry::Finish(Action::Control("formulas.recentlyUsed"))), // Excel: Recently Used Menu; opens the Recently Used menu
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
+7
View File
@@ -1668,6 +1668,13 @@
"note": "group level: containment(More Functions)",
"target": "formulas.moreFunctions"
},
{
"seq": "MR",
"kind": "control",
"excel": "Recently Used Menu",
"note": "opens the Recently Used menu",
"target": "formulas.recentlyUsed"
},
{
"seq": "MS",
"kind": "continue",
+1
View File
@@ -32,6 +32,7 @@ mod floating_pictures;
pub mod fonts;
pub mod formula_bar;
mod formula_locale;
pub mod function_library;
pub mod grid;
pub mod i18n;
pub mod icons;
+28
View File
@@ -291,3 +291,31 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
}
v
}
#[cfg(test)]
mod tooltip_tests {
/// A ribbon tooltip names its control only; `widgets::tooltip_shortcut` adds the shortcut of
/// the platform it runs on. A Command-key glyph written into the text would show on Windows
/// and Linux, where the shortcut is a Ctrl or Alt one (AutoSum is Alt+=).
#[test]
fn tooltips_leave_the_shortcut_to_the_platform_lookup() {
let sources = [
include_str!("ribbon.rs"),
include_str!("ribbon/automate.rs"),
include_str!("ribbon/contextual.rs"),
include_str!("ribbon/controls.rs"),
include_str!("ribbon/data.rs"),
include_str!("ribbon/formulas.rs"),
include_str!("ribbon/galleries.rs"),
include_str!("ribbon/home.rs"),
include_str!("ribbon/insert.rs"),
include_str!("ribbon/page_layout.rs"),
include_str!("ribbon/review.rs"),
include_str!("ribbon/shortcuts.rs"),
include_str!("ribbon/title_bar.rs"),
include_str!("ribbon/view.rs"),
];
let mac_only = sources.iter().flat_map(|s| s.lines()).filter(|l| l.contains('\u{2318}')).collect::<Vec<_>>();
assert!(mac_only.is_empty(), "Command-key glyph in ribbon text: {mac_only:?}");
}
}
+86 -36
View File
@@ -1,6 +1,8 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, keytip, menu_items, popup_command, sep};
use super::home::autosum_menu;
use super::{Icon, SheetApp, Ui, big_button, big_split_button, json, keytip_table, small_button, small_split_button};
use super::{Cat, Icon, SheetApp, Tokens, Ui, big_button, big_split_button, json, keytip_table, small_button, small_split_button};
use crate::function_library;
use crate::icons::FnGlyph;
use crate::widgets::small_button_accent;
/// How many cells Formulas › Error Checking › Circular References lists.
const CIRCULAR_MENU_LIMIT: usize = 100;
@@ -19,6 +21,57 @@ fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> {
.collect()
}
/// What a Function Library button's menu lists.
#[derive(Clone, Copy, Debug)]
enum LibraryMenu {
Recent,
Category(&'static str),
More,
}
/// One Function Library category button: its label, its menu, the glyph and colour of its book,
/// and the control id its keytip sequence targets in `keytips.json`.
struct LibraryItem {
label: &'static str,
menu: LibraryMenu,
book: (FnGlyph, Cat),
keytip: &'static str,
}
impl LibraryItem {
const fn new(label: &'static str, menu: LibraryMenu, book: (FnGlyph, Cat), keytip: &'static str) -> LibraryItem {
LibraryItem { label, menu, book, keytip }
}
}
/// The Function Library categories after AutoSum, column by column (three rows each). Every
/// category has its own colour.
const LIBRARY: [LibraryItem; 8] = [
LibraryItem::new("Recently Used", LibraryMenu::Recent, (FnGlyph::Star, Cat::Amber), "formulas.recentlyUsed"),
LibraryItem::new("Financial", LibraryMenu::Category("Financial"), (FnGlyph::Coins, Cat::Green), "formulas.financial"),
LibraryItem::new("Logical", LibraryMenu::Category("Logical"), (FnGlyph::Question, Cat::Purple), "formulas.logical"),
LibraryItem::new("Text", LibraryMenu::Category("Text"), (FnGlyph::Letter, Cat::Blue), "formulas.text"),
LibraryItem::new("Date & Time", LibraryMenu::Category("DateTime"), (FnGlyph::Clock, Cat::Orange), "formulas.dateTime"),
LibraryItem::new("Lookup & Reference", LibraryMenu::Category("Lookup"), (FnGlyph::Magnifier, Cat::Teal), "formulas.lookup"),
LibraryItem::new("Math & Trig", LibraryMenu::Category("MathTrig"), (FnGlyph::Theta, Cat::Red), "formulas.mathTrig"),
LibraryItem::new("More Functions", LibraryMenu::More, (FnGlyph::Dots, Cat::Slate), "formulas.moreFunctions"),
];
/// One Function Library category button: a book in the category's colour that opens the
/// category's menu.
fn library_button(app: &mut SheetApp, ui: &mut Ui, item: &LibraryItem) {
let (glyph, cat) = item.book;
let accent = Some(Tokens::get(ui.ctx()).cat(cat));
let anchor = small_button_accent(ui, Icon::FnBook(glyph), accent, item.label, item.label, true);
control_keytip(app, ui, &anchor, item.keytip);
let forced = invoke(app, item.keytip);
egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| match item.menu {
LibraryMenu::Recent => function_library::recent_menu(app, ui),
LibraryMenu::Category(id) => function_library::category_menu(app, ui, id),
LibraryMenu::More => function_library::more_menu(app, ui),
});
}
/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula
/// language, descriptions in the interface language), defined names, auditing and calculation.
pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
@@ -27,42 +80,21 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
if __k.clicked() {
app.open_dialog("insertFunction", json!({}));
}
let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
control_keytip(app, ui, &sa, "formulas.autoSum");
if s.clicked() {
// Function Library: AutoSum as a big split button, then the categories in columns of three
// labelled small buttons, as Excel lays the group out when the window is not wide enough for
// a big button per category.
let (sum, sum_arrow) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
control_keytip(app, ui, &sum_arrow, "formulas.autoSum");
if sum.clicked() {
act(app, "formulas.autoSum", json!({}));
}
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
for (label, cat, cid) in [
("Financial", "Financial", "formulas.financial"),
("Logical", "Logical", "formulas.logical"),
("Text", "Text", "formulas.text"),
("Date &\nTime", "DateTime", "formulas.dateTime"),
("Lookup &\nReference", "Lookup", "formulas.lookup"),
("Math &\nTrig", "MathTrig", "formulas.mathTrig"),
("More\nFunctions", "Statistical", "formulas.moreFunctions"),
] {
let r = big_button(ui, Icon::Book, label, label, true);
control_keytip(app, ui, &r, cid);
let forced_c = invoke(app, cid);
egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| {
ui.set_max_height(420.0);
egui::ScrollArea::vertical().show(ui, |ui| {
for f in gridcraft_engine::cmd::formulas::function_list() {
if f["category"].as_str() == Some(cat)
&& let Some(n) = f["name"].as_str()
{
let locale = app.ui.language.formula_locale();
let name =
app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n);
let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default();
if ui.button(name).on_hover_text(desc).clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": n}));
ui.close();
}
}
}
});
let forced = invoke(app, "formulas.autoSum");
egui::Popup::menu(&sum_arrow).open_memory(popup_command(forced, sum_arrow.clicked())).show(|ui| function_library::auto_sum_menu(app, ui));
for column in LIBRARY.chunks(3) {
ui.vertical(|ui| {
for item in column {
library_button(app, ui, item);
}
});
}
sep(ui);
@@ -200,3 +232,21 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
}
});
}
#[cfg(test)]
mod function_library_tests {
use super::*;
#[test]
fn every_function_library_book_has_its_own_glyph_and_colour() {
let books: Vec<(FnGlyph, Cat)> = LIBRARY.iter().map(|i| i.book).collect();
assert_eq!(books.len(), 8, "every category is a coloured book");
for (i, (glyph, cat)) in books.iter().enumerate() {
assert!(books.iter().skip(i + 1).all(|(g, c)| g != glyph && c != cat), "{glyph:?} / {cat:?} is used twice");
}
assert!(matches!(LIBRARY.first().map(|i| i.menu), Some(LibraryMenu::Recent)));
for item in &LIBRARY {
assert!(!keytip_table::sequences_for(item.keytip).is_empty(), "{} has a keytip sequence", item.label);
}
}
}
+1 -15
View File
@@ -480,7 +480,7 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
if s {
act(app, "formulas.autoSum", json!({}));
}
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
egui::Popup::menu(&sa).show(|ui| crate::function_library::auto_sum_menu(app, ui));
});
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
for sequence in keytip_table::group_extension(
@@ -573,20 +573,6 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
});
}
/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
/// Function dialog.
pub(super) fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
if ui.button(f).clicked() {
act(app, "formulas.autoSum", json!({"function": f}));
}
}
ui.separator();
if ui.button("More Functions…").clicked() {
app.open_dialog("insertFunction", json!({}));
}
}
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let name = st.font.name.clone();
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
+7 -1
View File
@@ -183,6 +183,12 @@ fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>],
/// Small ribbon button: 16px icon with optional label to the right.
pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response {
small_button_accent(ui, icon, None, label, tip, dropdown)
}
/// Small ribbon button whose icon takes an accent colour (see [`icons::paint_accent`]); `None`
/// draws it like [`small_button`]. A labelled button reports its label to accessibility.
pub fn small_button_accent(ui: &mut Ui, icon: Icon, accent: Option<Color32>, label: &str, tip: &str, dropdown: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(12.5);
let label = tr(ui, label);
@@ -194,7 +200,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b
} else if resp.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text);
icons::paint_accent(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text, accent);
if !label.is_empty() {
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text);
}
+5 -2
View File
@@ -152,8 +152,11 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() {
let (mut h, id) = fixture();
let inside = corner(&h, id) - vec2(100.0, 80.0);
right_click(&mut h, inside, vec2(0.0, 0.0));
// Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last.
h.get_all_by_role_and_label(Role::Button, "Delete").last().unwrap().click();
// Home › Cells has a Delete button too: take the one in the chart's menu, by the click.
h.query_all_by_label("Delete")
.min_by(|a, b| a.rect().center().distance(inside).total_cmp(&b.rect().center().distance(inside)))
.expect("the chart menu has Delete")
.click();
h.run_steps(3);
assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty());
assert!(h.state().grid.object_menu.is_none());
+332
View File
@@ -0,0 +1,332 @@
//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions
//! 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};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn formulas_tab(width: f32) -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut app = crate::common::app(session);
app.ui.ribbon_tab = "Formulas".into();
crate::common::harness_with(crate::common::builder().with_os(OperatingSystem::Nix).with_size(vec2(width, 760.0)), app, 4)
}
fn editor_text(h: &Harness) -> Option<String> {
h.state().editor.as_ref().map(|e| e.text.clone())
}
fn input(h: &Harness, cell: &str) -> String {
let d = h.state().session.active().unwrap();
d.wb.active().unwrap().input_text(CellRef::parse(cell).unwrap())
}
/// Clicks a button by its name (a tooltip also names an icon-only button, such as the formula
/// bar's fx, which is not a button to accessibility).
fn click(h: &mut Harness, label: &str) {
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
h.run_steps(3);
}
/// Clicks a menu row through accessibility, which reaches rows scrolled out of a long menu.
fn pick(h: &mut Harness, label: &str) {
h.get_by_label(label).click_accesskit();
h.run_steps(3);
}
fn close_menus(h: &mut Harness) {
h.key_press(Key::Escape);
h.run_steps(2);
h.state_mut().editor = None;
h.run_steps(2);
}
#[test]
fn autosum_is_a_big_split_button_then_the_categories_in_columns_of_three() {
let h = formulas_tab(1440.0);
let columns: [&[&str]; 3] =
[&["Recently Used", "Financial", "Logical"], &["Text", "Date & Time", "Lookup & Reference"], &["Math & Trig", "More Functions"]];
let rect = |l: &str| h.get_by_role_and_label(egui::accesskit::Role::Button, l).rect();
for column in columns {
for pair in column.windows(2) {
let (a, b) = (rect(pair[0]), rect(pair[1]));
assert!((a.left() - b.left()).abs() < 0.5, "{pair:?} share a left edge");
assert!(a.bottom() <= b.top() + 0.5, "{pair:?} stack in order");
}
}
for pair in columns.windows(2) {
assert!(rect(pair[0][0]).right() <= rect(pair[1][0]).left(), "columns run left to right");
}
// Insert Function and AutoSum are big buttons, to the left of the columns.
let (insert, sum) = (rect("Insert Function"), rect("AutoSum"));
assert!(insert.right() <= sum.left() && sum.right() <= rect("Recently Used").left());
assert!(sum.top() <= rect("Recently Used").top() + 0.5, "AutoSum starts at the top of the group");
assert!(h.get_by_label("AutoSum options").rect().bottom() > rect("Financial").bottom(), "its arrow part reaches below the second row");
// A full column stands where the tab's other columns of three small buttons do (Formula
// Auditing's), so it fits the ribbon in any font.
let (top, bottom) = (rect("Show Formulas").top(), rect("Evaluate Formula").bottom());
for column in &columns[..2] {
let (t, b) = (rect(column[0]).top(), rect(column[2]).bottom());
assert!((t - top).abs() < 0.5 && (b - bottom).abs() < 0.5, "{column:?} spans {t}..{b}, Formula Auditing {top}..{bottom}");
}
}
#[test]
fn each_category_menu_lists_its_functions_then_insert_function() {
let mut h = formulas_tab(1440.0);
// Recently Used lists the functions of formulas entered.
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap();
h.run_steps(2);
for (button, function) in [
("Recently Used", "SUM"),
("Financial", "PMT"),
("Logical", "IF"),
("Text", "CONCAT"),
("Date & Time", "TODAY"),
("Lookup & Reference", "XLOOKUP"),
("Math & Trig", "ROUND"),
] {
click(&mut h, button);
h.get_by_label("Insert Function…");
pick(&mut h, function);
assert_eq!(editor_text(&h).as_deref(), Some(format!("={function}(").as_str()), "{button} › {function} starts the formula");
close_menus(&mut h);
}
// The menu's last row opens Insert Function on the menu's category.
click(&mut h, "Date & Time");
click(&mut h, "Insert Function…");
let d = h.state().dialog.as_ref().expect("Insert Function opens");
assert_eq!(d.name(), "insertFunction");
assert_eq!(d.values.get("category"), Some(&json!("Date & Time")));
}
#[test]
fn more_functions_cascades_into_the_remaining_categories() {
let mut h = formulas_tab(1440.0);
click(&mut h, "More Functions");
for c in ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"] {
h.get_by_label(c);
}
click(&mut h, "Engineering");
h.get_by_label("Insert Function…");
pick(&mut h, "BIN2DEC");
assert_eq!(editor_text(&h).as_deref(), Some("=BIN2DEC("));
close_menus(&mut h);
click(&mut h, "More Functions");
click(&mut h, "Statistical");
pick(&mut h, "Insert Function…");
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("category")), Some(&json!("Statistical")));
}
#[test]
fn autosum_runs_sum_and_its_arrow_opens_the_shared_menu() {
let mut h = formulas_tab(1440.0);
{
let s = &mut h.state_mut().session;
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 4], [2, 5], [3, 6]]})).unwrap();
s.execute("selection.set", json!({"cell": "A4"})).unwrap();
}
h.run_steps(2);
click(&mut h, "AutoSum");
assert_eq!(input(&h, "A4"), "=SUM(A1:A3)");
h.state_mut().session.execute("selection.set", json!({"cell": "B4"})).unwrap();
click(&mut h, "AutoSum options");
for item in ["Sum", "Average", "Max", "Min", "More Functions…"] {
h.get_by_label(item);
}
click(&mut h, "Count Numbers");
assert_eq!(input(&h, "B4"), "=COUNT(B1:B3)");
click(&mut h, "AutoSum options");
click(&mut h, "More Functions…");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("insertFunction"));
}
fn key_event(key: Key, pressed: bool) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
}
#[test]
fn the_recently_used_keytip_opens_its_menu() {
let mut h = formulas_tab(1440.0);
// Recently Used lists the functions of formulas entered.
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap();
h.run_steps(2);
h.input_mut().events.extend([key_event(Key::F10, true), key_event(Key::F10, false)]);
h.run_steps(2);
for k in [Key::M, Key::R] {
h.input_mut().events.extend([key_event(k, true), Event::Text(k.name().to_ascii_lowercase()), key_event(k, false)]);
h.run_steps(3);
}
assert_eq!(h.state().keytips.prefix(), None, "MR finishes the sequence");
h.get_by_label("Insert Function…");
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");
}
#[test]
fn insert_function_lets_tab_reach_the_category_list() {
use egui::accesskit::Role;
let mut h = formulas_tab(1440.0);
click(&mut h, "Insert Function");
h.run_steps(3);
let search_focused = |h: &Harness| h.get_all_by_role(Role::TextInput).any(|n| n.is_focused());
assert!(search_focused(&h), "the search box takes the focus when the dialog opens");
h.input_mut().events.push(Event::Text("npv".into()));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.search.as_str()), Some("npv"));
// Tab moves on to the category list, and the search box does not take the focus back.
h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]);
h.run_steps(5);
assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list");
assert!(!search_focused(&h));
// The list opens from the keyboard (its Date & Time joins the ribbon's).
let listed = |h: &Harness| h.get_all_by_label("Date & Time").count();
let before = listed(&h);
h.input_mut().events.extend([key_event(Key::Space, true), key_event(Key::Space, false)]);
h.run_steps(3);
assert_eq!(listed(&h), before + 1, "the list is open");
}
#[test]
fn enter_on_the_category_list_does_not_confirm_insert_function() {
use egui::accesskit::Role;
let mut h = formulas_tab(1440.0);
click(&mut h, "Insert Function");
h.run_steps(3);
let open = |h: &Harness| h.state().dialog.as_ref().is_some_and(|d| d.name == "insertFunction");
let listed = |h: &Harness| h.get_all_by_label("Date & Time").count();
let closed = listed(&h);
h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]);
h.run_steps(5);
assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list");
// Enter on the focused list opens it, as Space does; the dialog stays open and nothing is
// inserted.
h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
h.run_steps(3);
assert!(open(&h), "Enter on the category list leaves the dialog open");
assert_eq!(editor_text(&h), None, "no function is inserted");
assert_eq!(listed(&h), closed + 1, "Enter opens the list");
// Enter again, with the list open, closes it and still does not confirm.
h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
h.run_steps(3);
assert!(open(&h), "Enter in the open list leaves the dialog open");
assert_eq!(editor_text(&h), None);
// Back in the search box (Shift+Tab), Enter confirms as before.
let shift_tab = |pressed| Event::Key { key: Key::Tab, physical_key: None, pressed, repeat: false, modifiers: Modifiers::SHIFT };
h.input_mut().events.extend([shift_tab(true), shift_tab(false)]);
h.run_steps(3);
assert!(!h.get_by_role(Role::ComboBox).is_focused(), "Shift+Tab leaves the category list");
h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
h.run_steps(3);
assert!(!open(&h), "Enter in the search box confirms");
assert!(editor_text(&h).is_some_and(|t| t.starts_with('=') && t.ends_with('(')), "{:?}", editor_text(&h));
}
/// 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);
}
}
+9 -1
View File
@@ -1771,6 +1771,14 @@ fn keytip_mq_() {
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_mr_() {
let mut h = harness(blank());
sequence(&mut h, "MR", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "MR must route to its control without an error box");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_ms_() {
let mut h = harness(blank());
@@ -2761,7 +2769,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
const EXPECTED: usize = 366;
const EXPECTED: usize = 367;
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
}
+1
View File
@@ -47,6 +47,7 @@ mod formula_locale;
mod formula_point_headers;
mod formula_popup;
mod freeze_handles;
mod function_library;
mod go_to_dialog;
mod header_autofit;
mod header_column_drag;
+4 -4
View File
@@ -135,15 +135,15 @@ fn big_split_button_separates_the_default_action_from_the_menu() {
}
#[test]
fn formulas_autosum_runs_on_the_icon_and_opens_its_menu_on_the_label() {
fn formulas_autosum_runs_sum_and_its_arrow_opens_the_menu() {
let mut h = app_at(1600.0, "Formulas");
h.get_by_label("AutoSum").click();
h.run_steps(2);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the icon runs AutoSum");
assert!(h.query_by_label("AVERAGE").is_none());
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the button runs AutoSum");
assert!(h.query_by_label("Average").is_none());
h.get_by_label("AutoSum options").click();
h.run_steps(2);
h.get_by_label("AVERAGE").click();
h.get_by_label("Average").click();
h.run_steps(2);
let last = h.state().session.journal.iter().rev().find(|(id, _)| id == "formulas.autoSum").map(|(_, p)| p.clone());
assert_eq!(last.and_then(|p| p.get("function").and_then(|f| f.as_str().map(str::to_string))).as_deref(), Some("AVERAGE"));
+23 -6
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (the hidden names that hold macros; earlier: macro engine on the engine building blocks) · **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).
@@ -59,13 +59,20 @@ drawing its own:
`icons::paint_on_sheet`) keep their light values. `theme::contrast_tests` holds every
foreground/background pair to WCAG 2 (4.5:1 for text, 3:1 for glyphs) in both themes; a new
token pair is added there. Icons (`icons.rs`, drawn in code) take their paper and category
colours (`theme::Cat`) from the tokens.
colours (`theme::Cat`) from the tokens; `icons::paint_accent` gives an icon an accent colour,
drawn on its coloured part when it has one (the spine and glyph of `Icon::FnBook`).
- **Ribbon widgets**: `widgets.rs` has the buttons every tab is built from (`big_button`,
`small_button`, `split_button`, `big_split_button`, `toggle_button`), translated through
`i18n` in one place. Split buttons return the main and arrow responses separately, with
accessible names (`AutoSum`, `AutoSum options`). The ribbon card scrolls a tab wider than the
window, with arrows at its edges; `ribbon::RibbonOverflow` records the groups' size each frame,
`small_button`, `small_button_accent`, `split_button`, `big_split_button`, `toggle_button`),
translated through `i18n` in one place. Split buttons return the main and arrow responses
separately, with accessible names (`AutoSum`, `AutoSum options`); big and small buttons
report their label (an icon-only one its tooltip) as their accessible name. The ribbon card
scrolls a tab wider than the window, with arrows at its edges; `ribbon::RibbonOverflow` records the groups' size each frame,
and a test keeps every tab within the card's three rows.
- **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. 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,
@@ -102,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
@@ -159,6 +174,8 @@ 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`) |
| 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage |
+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 |
+6 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: 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; Esc leaves a File-page text field before closing the view; 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,6 +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; 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 |
@@ -156,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 +197,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) |
| 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 |
| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |