mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 07:06:48 -06:00
Engine: Recently Used follows the formulas entered; Insert Function category list
formulas.recentlyUsed scanned the session journal for cell.set inputs. It missed formulas entered by range.* commands, lost them when the journal dropped its oldest entries (a large file open or a long script is enough), and started empty in every session. The Function Library's Recently Used menu and the Insert Function dialog list what it returns. - Prefs::recent_functions (a new engine module, recent.rs) holds the ten functions most recently entered in a formula, newest first. Session::execute updates it for every cell.* and range.* command it journals, from any source (the interface, the control channel, MCP or a script). It reads at most 2,000 of the formulas a command enters, starting from the last one; formulas longer than Excel's limit are skipped. formulas.recentlyUsed returns it. It never reads the journal back, so the journal's caps do not affect it. - The list is kept with the other engine preferences (prefs.json, the browser's storage on the web), so it survives a restart as Excel's does. It is read leniently: a malformed value loses only this list. A fresh install starts from common functions. The Insert Function dialog is titled "Insert Function", as in Excel (it said "Formula Builder"), and gains a working category list: Most Recently Used, All, then each category in Excel's order. A category may be given by label or by engine id, so a Function Library menu or the control channel (ui.dialog.set) opens it on one, and a new search switches to All. The dialog keeps its translated labels and its search in the interface language. Each row shows the function's name and its category in two columns; the category column starts after the widest name shown, so a long name in any font or formula language stays clear of it. A function counts as recently used once its formula is entered, not when OK is pressed. The unused "Formula Builder" translations are removed. Tests: engine unit tests for reading functions from formulas (strings, unknown names, over-long and malformed formulas), the formulas a command enters (newest first, within the budget), the ten-name list and the lenient read of prefs.json; tests/it/recently_used.rs for cell.set and range.setValues from the session, a failed command, a cleared journal and a save and reload of the preferences. UI tests for the menu's order and the dialog's title, categories, engine-id category, search and OK, and for the category column with a wide font.
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used follows the formulas entered; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
|
||||
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
|
||||
@@ -245,6 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
| 2026-10-31 | minor | Progress log: Function Library |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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"}))),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod outline;
|
||||
pub mod paper;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod recent;
|
||||
pub mod record;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
@@ -390,6 +391,10 @@ pub struct Prefs {
|
||||
pub formula_bar: bool,
|
||||
/// The statistics the status bar shows for a selection (right-click the status bar).
|
||||
pub status_bar: StatusBarStats,
|
||||
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
|
||||
/// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel.
|
||||
#[serde(deserialize_with = "recent::lenient")]
|
||||
pub recent_functions: Vec<String>,
|
||||
}
|
||||
|
||||
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
|
||||
@@ -424,6 +429,7 @@ impl Default for Prefs {
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
status_bar: StatusBarStats::default(),
|
||||
recent_functions: recent::defaults(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,6 +796,7 @@ impl Session {
|
||||
}
|
||||
if self.journal_paused == 0 {
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
recent::note(&mut self.prefs.recent_functions, id, ¶ms);
|
||||
}
|
||||
}
|
||||
// Inside an undo group the group owns the step. A command that ended a group open before
|
||||
|
||||
@@ -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, ¶ms, 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -856,7 +856,7 @@ impl Dialog {
|
||||
"insertFunction" => {
|
||||
// A Function Library menu opens it on its own category.
|
||||
let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string();
|
||||
Dialog::custom("insertFunction", "Formula Builder", json!({"category": category}))
|
||||
Dialog::custom("insertFunction", "Insert Function", json!({"category": category}))
|
||||
}
|
||||
"find" => {
|
||||
let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"}));
|
||||
|
||||
@@ -1,33 +1,87 @@
|
||||
use super::show::ok_cancel;
|
||||
use super::{Dialog, Json, SheetApp, vec2};
|
||||
use super::{Dialog, Json, SheetApp, json, vec2};
|
||||
|
||||
/// Insert Function: searches names (as the formula language spells them) and descriptions, shows
|
||||
/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula).
|
||||
/// Insert Function: search names (as the formula language spells them) and descriptions, or pick
|
||||
/// a category (Most Recently Used, All or one category), then a function; each hit shows its
|
||||
/// category and description. OK starts editing `=NAME(` (or appends to the formula).
|
||||
pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
use crate::function_library::{self as lib, ALL, MOST_RECENTLY_USED};
|
||||
let language = app.ui.language;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(language.tr("Search:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date")));
|
||||
r.request_focus();
|
||||
});
|
||||
// A new search (typed, or set through the control channel) looks through every function, as
|
||||
// Excel's does.
|
||||
if d.values.get("searched").and_then(Json::as_str) != Some(d.search.as_str()) {
|
||||
d.list_index = 0;
|
||||
if !d.search.trim().is_empty() {
|
||||
d.values.insert("category".into(), json!(ALL));
|
||||
}
|
||||
d.values.insert("searched".into(), json!(d.search));
|
||||
}
|
||||
// The category may be given by label or by engine id (a Function Library menu, `ui.dialog.set`).
|
||||
let saved = d.values.get("category").and_then(Json::as_str).unwrap_or(MOST_RECENTLY_USED).to_string();
|
||||
let mut category = if saved.eq_ignore_ascii_case(ALL) {
|
||||
ALL.to_string()
|
||||
} else {
|
||||
lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string()
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(language.tr("Or select a category:"));
|
||||
craft_ui::ComboBox::from_id_salt("insertFunctionCategory")
|
||||
.width(220.0)
|
||||
.selected_text(language.tr(&category))
|
||||
.show_ui(ui, |ui| {
|
||||
for c in [MOST_RECENTLY_USED, ALL].into_iter().chain(lib::CATEGORIES.iter().map(|(_, l)| *l)) {
|
||||
ui.selectable_value(&mut category, c.to_string(), language.tr(c));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
if category != saved {
|
||||
d.list_index = 0;
|
||||
}
|
||||
d.values.insert("category".into(), json!(category));
|
||||
let locale = language.formula_locale();
|
||||
let list = gridcraft_engine::cmd::formulas::function_list();
|
||||
// The names and descriptions searched are the ones shown, so in the interface language.
|
||||
let q = d.search.to_lowercase();
|
||||
let hits: Vec<&Json> = list
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
q.is_empty()
|
||||
|| f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q))
|
||||
|| f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q))
|
||||
})
|
||||
.take(300)
|
||||
.collect();
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for (i, f) in hits.iter().enumerate() {
|
||||
let name = locale.function_name(f["name"].as_str().unwrap_or(""));
|
||||
let category = language.tr(crate::function_library::category_label(f["category"].as_str().unwrap_or("")));
|
||||
let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0)));
|
||||
let q = d.search.trim().to_lowercase();
|
||||
let matches = |f: &lib::FnEntry| {
|
||||
q.is_empty()
|
||||
|| locale.function_name(&f.name).to_lowercase().contains(&q)
|
||||
|| language.function_description(&f.description).unwrap_or(&f.description).to_lowercase().contains(&q)
|
||||
};
|
||||
let hits: Vec<&lib::FnEntry> = if category == MOST_RECENTLY_USED {
|
||||
lib::recent(app).iter().filter_map(|n| lib::find(n)).filter(|f| matches(f)).collect()
|
||||
} else {
|
||||
let id = lib::category_id(&category);
|
||||
// The catalog is fixed (some 500 functions), so All lists every one.
|
||||
lib::all().iter().filter(|f| id.is_none_or(|id| f.category == id) && matches(f)).collect()
|
||||
};
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let rows: Vec<(&str, &str)> = hits.iter().map(|f| (locale.function_name(&f.name), language.tr(lib::category_label(&f.category)))).collect();
|
||||
// The category column starts after the widest name shown, so a long name in any formula
|
||||
// language or font never runs into it.
|
||||
let name_w = rows.iter().map(|(n, _)| ui.painter().layout_no_wrap(n.to_string(), font.clone(), t.text).size().x).fold(0.0_f32, f32::max);
|
||||
egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
if rows.is_empty() {
|
||||
ui.label(egui::RichText::new(language.tr("No functions match.")).italics());
|
||||
}
|
||||
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
|
||||
for (i, (name, category)) in rows.iter().enumerate() {
|
||||
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width().max(name_w + 120.0), row_h), egui::Sense::click());
|
||||
if i == d.list_index {
|
||||
ui.painter().rect_filled(rect, 3.0, ui.visuals().selection.bg_fill);
|
||||
} else if r.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let color = if i == d.list_index { ui.visuals().selection.stroke.color } else { t.text };
|
||||
ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, *name, font.clone(), color);
|
||||
ui.painter().text(rect.left_center() + vec2(6.0 + name_w + 16.0, 0.0), egui::Align2::LEFT_CENTER, *category, font.clone(), t.text_dim);
|
||||
r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == d.list_index, format!("{name} ({category})")));
|
||||
if r.clicked() {
|
||||
d.list_index = i;
|
||||
}
|
||||
@@ -38,15 +92,19 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia
|
||||
}
|
||||
});
|
||||
if let Some(f) = hits.get(d.list_index) {
|
||||
let name = f["name"].as_str().unwrap_or("");
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong());
|
||||
ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default());
|
||||
ui.label(egui::RichText::new(crate::formula_locale::signature(&f.name, locale, language).unwrap_or_default()).strong());
|
||||
ui.label(crate::formula_locale::description(&f.name, locale, language).unwrap_or_default());
|
||||
}
|
||||
let mut open = true;
|
||||
ok_cancel(ui, confirm, &mut open);
|
||||
if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
|
||||
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n);
|
||||
// The function counts as recently used once the formula is entered (the engine notes it).
|
||||
if *confirm && let Some(f) = hits.get(d.list_index) {
|
||||
let n = app
|
||||
.session
|
||||
.active()
|
||||
.map(|doc| crate::formula_locale::completion_name(&f.name, locale, &doc.wb, doc.wb.active_sheet()))
|
||||
.unwrap_or(&f.name);
|
||||
let cur = app.editor.as_ref().map(|e| e.text.clone());
|
||||
let text = match cur {
|
||||
Some(t) if t.starts_with('=') => format!("{t}{n}("),
|
||||
|
||||
@@ -577,7 +577,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Edit Name" => "Επεξεργασία ονόματος",
|
||||
"New Name" => "Νέο όνομα",
|
||||
"Format Cells" => "Μορφοποίηση κελιών",
|
||||
"Formula Builder" => "Δημιουργός τύπων",
|
||||
"Find and Replace" => "Εύρεση και αντικατάσταση",
|
||||
"Go To" => "Μετάβαση",
|
||||
"Insert Calculated Field" => "Εισαγωγή υπολογιζόμενου πεδίου",
|
||||
|
||||
@@ -453,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Edit Name" => "Modifica nome",
|
||||
"New Name" => "Nuovo nome",
|
||||
"Format Cells" => "Formato celle",
|
||||
"Formula Builder" => "Generatore di formule",
|
||||
"Find and Replace" => "Trova e sostituisci",
|
||||
"Go To" => "Vai a",
|
||||
"Insert Calculated Field" => "Inserisci campo calcolato",
|
||||
|
||||
@@ -475,7 +475,6 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
|
||||
("Edit Name", "名前の編集"),
|
||||
("New Name", "新しい名前"),
|
||||
("Format Cells", "セルの書式設定"),
|
||||
("Formula Builder", "数式ビルダー"),
|
||||
("Find and Replace", "検索と置換"),
|
||||
("Go To", "ジャンプ"),
|
||||
("Insert Calculated Field", "計算のフィールドの挿入"),
|
||||
|
||||
@@ -447,7 +447,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Edit Name" => "Edytowanie nazwy",
|
||||
"New Name" => "Nowa nazwa",
|
||||
"Format Cells" => "Formatowanie komórek",
|
||||
"Formula Builder" => "Konstruktor formuł",
|
||||
"Find and Replace" => "Znajdowanie i zamienianie",
|
||||
"Go To" => "Przechodzenie do",
|
||||
"Insert Calculated Field" => "Wstawianie pola obliczeniowego",
|
||||
|
||||
@@ -469,7 +469,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Edit Name" => "Editar Nome",
|
||||
"New Name" => "Novo Nome",
|
||||
"Format Cells" => "Formatar Células",
|
||||
"Formula Builder" => "Construtor de Fórmulas",
|
||||
"Find and Replace" => "Localizar e Substituir",
|
||||
"Go To" => "Ir para",
|
||||
"Insert Calculated Field" => "Inserir Campo Calculado",
|
||||
|
||||
@@ -453,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Edit Name" => "Editare nume",
|
||||
"New Name" => "Nume nou",
|
||||
"Format Cells" => "Formatare celule",
|
||||
"Formula Builder" => "Generator de formule",
|
||||
"Find and Replace" => "Găsire și înlocuire",
|
||||
"Go To" => "Salt la",
|
||||
"Insert Calculated Field" => "Inserare câmp calculat",
|
||||
|
||||
@@ -451,7 +451,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Edit Name" => "Edit Name",
|
||||
"New Name" => "New Name",
|
||||
"Format Cells" => "Format Cells",
|
||||
"Formula Builder" => "Formula Builder",
|
||||
"Find and Replace" => "Find and Replace",
|
||||
"Go To" => "Go To",
|
||||
"Insert Calculated Field" => "Insert Calculated Field",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions
|
||||
//! cascade, the AutoSum big split button and the Recently Used keytip.
|
||||
//! cascade, the AutoSum big split button, the Recently Used keytip and list, and the Insert Function
|
||||
//! dialog's categories.
|
||||
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
@@ -169,3 +170,104 @@ fn the_recently_used_keytip_opens_its_menu() {
|
||||
pick(&mut h, "AVERAGE");
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=AVERAGE("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_used_lists_the_engines_list_newest_first() {
|
||||
let mut h = formulas_tab(1440.0);
|
||||
// The ribbon and the control channel go through app.run; MCP and scripts through the session.
|
||||
// The engine keeps the list (see the engine's recently_used tests); the menu shows it.
|
||||
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
|
||||
h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap();
|
||||
h.state_mut().session.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(&h.state().session.prefs.recent_functions[..4], ["FV", "IRR", "ROUND", "NPV"]);
|
||||
click(&mut h, "Recently Used");
|
||||
let top = |h: &Harness, name: &str| h.get_by_label(name).rect().top();
|
||||
assert!(top(&h, "FV") < top(&h, "IRR") && top(&h, "IRR") < top(&h, "ROUND") && top(&h, "ROUND") < top(&h, "NPV"));
|
||||
}
|
||||
|
||||
/// Opens the Insert Function category list (the dialog's only combo box).
|
||||
fn open_categories(h: &mut Harness) {
|
||||
h.get_by_role(egui::accesskit::Role::ComboBox).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Picks a category from the open list (the ribbon has buttons with the same names).
|
||||
fn choose(h: &mut Harness, category: &str) {
|
||||
h.get_all_by_label(category).last().expect("the category is listed").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn row(name: &str, category: &str) -> String {
|
||||
format!("{name} ({category})")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_function_has_its_title_and_a_working_category_list() {
|
||||
let mut h = formulas_tab(1440.0);
|
||||
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
|
||||
h.run_steps(2);
|
||||
click(&mut h, "Insert Function");
|
||||
let d = h.state().dialog.as_ref().expect("Insert Function opens");
|
||||
assert_eq!(d.title, "Insert Function");
|
||||
// Most Recently Used comes first, newest first.
|
||||
let npv = h.get_by_label(&row("NPV", "Financial")).rect();
|
||||
assert!(npv.top() < h.get_by_label(&row("SUM", "Math & Trig")).rect().top());
|
||||
assert!(h.query_by_label(&row("PMT", "Financial")).is_some(), "the default list follows");
|
||||
// Pick a category from the list.
|
||||
open_categories(&mut h);
|
||||
choose(&mut h, "Date & Time");
|
||||
assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some());
|
||||
assert!(h.query_by_label(&row("NPV", "Financial")).is_none());
|
||||
// All lists everything; a category given by its engine id works too (control channel).
|
||||
open_categories(&mut h);
|
||||
choose(&mut h, "All");
|
||||
assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some() && h.query_by_label(&row("NPV", "Financial")).is_some());
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("category".into(), json!("MathTrig"));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label(&row("ROUND", "Math & Trig")).is_some() && h.query_by_label(&row("TODAY", "Date & Time")).is_none());
|
||||
// A search looks through every category.
|
||||
h.state_mut().dialog.as_mut().unwrap().search = "lookup".into();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("category"), Some(&json!("All")));
|
||||
assert!(h.query_by_label(&row("XLOOKUP", "Lookup & Reference")).is_some());
|
||||
// OK starts the formula. The function counts as recently used once the formula is entered,
|
||||
// not before (the edit may still be cancelled, or there may be no cell to edit).
|
||||
pick(&mut h, &row("XLOOKUP", "Lookup & Reference"));
|
||||
click(&mut h, " OK ");
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=XLOOKUP("));
|
||||
assert_ne!(h.state().session.prefs.recent_functions[0], "XLOOKUP");
|
||||
}
|
||||
|
||||
/// The text shapes the last frame painted, with their left edge and width.
|
||||
fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> {
|
||||
let mut out = Vec::new();
|
||||
for clipped in &h.output().shapes {
|
||||
if let egui::Shape::Text(t) = &clipped.shape {
|
||||
out.push((t.galley.text().to_string(), t.pos.x, t.galley.size().x));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_function_keeps_the_category_column_clear_of_long_names() {
|
||||
let mut h = formulas_tab(1440.0);
|
||||
// A wide interface font (as on some Windows and Linux desktops, or a long formula language)
|
||||
// makes every name wider than the dialog was laid out for.
|
||||
h.ctx.all_styles_mut(|s| {
|
||||
s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(20.0));
|
||||
});
|
||||
click(&mut h, "Insert Function");
|
||||
// Some of the longest function names in the catalog, and a short one, in one list (the
|
||||
// signature under the list starts with a name too, and is not a row).
|
||||
h.state_mut().dialog.as_mut().unwrap().search = "forecast".into();
|
||||
h.run_steps(3);
|
||||
let shapes = painted_text(&h);
|
||||
let names: Vec<&(String, f32, f32)> = shapes.iter().filter(|(t, _, _)| t.starts_with("FORECAST") && !t.contains('(')).collect();
|
||||
let column = shapes.iter().filter(|(t, _, _)| t == "Statistical").map(|(_, x, _)| *x).fold(f32::INFINITY, f32::min);
|
||||
assert!(names.iter().any(|(t, _, _)| t == "FORECAST.ETS.SEASONALITY"), "the long name is listed: {shapes:?}");
|
||||
for (name, x, w) in names {
|
||||
assert!(x + w < column, "{name} ends at {} and the category column starts at {column}", x + w);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
|
||||
|
||||
@@ -71,7 +71,8 @@ drawing its own:
|
||||
- **Function Library**: `function_library.rs` holds the function categories (engine id and
|
||||
label, in the Insert Function dialog's order), the function list built once from
|
||||
`cmd::formulas::function_list`, and the menus of Formulas › Function Library, including the
|
||||
AutoSum menu that Home › Editing shares.
|
||||
AutoSum menu that Home › Editing shares. Recently Used and the Insert Function dialog's Most
|
||||
Recently Used category list what `formulas.recentlyUsed` returns.
|
||||
- **Code editor**: `widgets::code_editor(ui, id, &mut text, Lang, max_lines)` (`code_editor.rs`)
|
||||
edits code in the monospace font with `Tokens::syntax` colours: Python (keywords, strings
|
||||
including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts,
|
||||
@@ -108,6 +109,14 @@ Actions made of many commands (macros, a debugger that runs one step per call) u
|
||||
a recording leaves it out, so playing the recording back never stops the run. The
|
||||
history limit is applied when the outermost command finishes; until then the steps made inside
|
||||
it are bounded on their own.
|
||||
- **Recently used functions** (`recent.rs`). `Prefs::recent_functions` holds the ten functions
|
||||
most recently entered in a formula, newest first. `Session::execute` updates it for every
|
||||
`cell.*` and `range.*` command it journals, from any source (interface, control channel, MCP,
|
||||
script). It reads at most 2,000 of the formulas a command enters, starting from the last one
|
||||
(the most recent), so a large paste costs no more than that; `formulas.recentlyUsed` returns
|
||||
it. It is kept with the other preferences (`prefs.json`, the browser's storage on the
|
||||
web) and read leniently, so a malformed value loses only this list. It does not read the
|
||||
journal back, so the journal's trimming does not affect it.
|
||||
- **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful
|
||||
top-level command with the document, sheet and selection it ran in, without secret parameters.
|
||||
Commands run by other commands are not recorded, nor is a menu-style call that only opened a
|
||||
@@ -165,6 +174,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | Recently used functions (`recent.rs`, `Prefs::recent_functions`) behind `formulas.recentlyUsed` |
|
||||
| 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) |
|
||||
| 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro_<n>_<k>` |
|
||||
| 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: GridCraft vs Microsoft Excel
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Insert Function dialog is named as in Excel; no score change; earlier: automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||
@@ -314,7 +314,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||
galleries (cell styles, table styles, colour palettes, conditional formatting menus),
|
||||
contextual Table/Chart Design tabs, formula bar with Name Box, virtualised grid (smooth
|
||||
scrolling, zoom, frozen panes, text overflow), sheet tabs (rename, reorder, colour, hide),
|
||||
status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Formula Builder, Find and
|
||||
status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Insert Function, Find and
|
||||
Replace, Sort, Name Manager, Data Validation, Paste Special…), command palette, dark mode.
|
||||
- Agents: every action is an engine command; JSON control channel in the desktop app (pointer,
|
||||
keyboard, dialogs, screenshots); MCP server (headless or bridged); CLI (`info`, `convert`,
|
||||
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change |
|
||||
| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) |
|
||||
| 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) |
|
||||
| 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change |
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
@@ -18,7 +18,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
||||
| Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) |
|
||||
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928); large split buttons (Formulas › AutoSum); arrows at the card's edges scroll a tab wider than the window | done; missing contextual tabs for pictures, shapes, sparklines, slicers; groups do not collapse when narrow |
|
||||
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) |
|
||||
| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; Recently Used lists what `formulas.recentlyUsed` returns |
|
||||
| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed |
|
||||
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers |
|
||||
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing |
|
||||
| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h |
|
||||
@@ -157,7 +157,8 @@ Insert Hyperlink loads an existing cell link's address, display text and ScreenT
|
||||
(#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel
|
||||
keeps the original link and an edit can be undone in one step.
|
||||
|
||||
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
|
||||
Format Cells, Insert Function (search, Most Recently Used, All and each category), Find and
|
||||
Replace, Go To and Go To Special, Sort,
|
||||
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
|
||||
Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a
|
||||
note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step
|
||||
@@ -195,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown |
|
||||
| 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used |
|
||||
| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text |
|
||||
| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |
|
||||
|
||||
Reference in New Issue
Block a user