diff --git a/ROADMAP.md b/ROADMAP.md index 3d1a1d0d8..328b92f40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **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; 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 | 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`) | @@ -244,6 +245,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: 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 | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 92187d5c0..df166e229 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -853,7 +853,11 @@ 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(); + Dialog::custom("insertFunction", "Formula Builder", json!({"category": category})) + } "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() }; diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index b662d7197..2f922d644 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -26,7 +26,7 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia 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 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))); if r.clicked() { d.list_index = i; @@ -58,15 +58,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, - } -} diff --git a/crates/ui-egui/src/function_library.rs b/crates/ui-egui/src/function_library.rs new file mode 100644 index 000000000..434f4a6ac --- /dev/null +++ b/crates/ui-egui/src/function_library.rs @@ -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> = OnceLock::new(); + LIST.get_or_init(|| { + let mut list: Vec = 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 + '_ { + 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) { + 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 { + 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()); + } +} diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 9a6d54030..3ba6cb732 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 6427b279d..420214ffb 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 9231f8244..165d94cd8 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -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" => "Σύστημα", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index a4b4e884a..f3df58b04 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdb20f7bf..e44696eb1 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 31bc6721c..51d1db6c7 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 1f06755dd..b7cb6c95b 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -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", "印刷する内容"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c54ed7eee..63159f7db 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -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" => "애니메이션", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index a28459f83..f45fe10a0 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index d7741a9c6..3b58b0d20 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index c79be64e1..0107235cf 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 058911d87..ade378b9d 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index b0e894095..f8d46d134 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -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" => "Анимация", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c9ce49e9e..4ff6aa4ce 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 68cd198bb..f09e64e07 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 72c1bf474..a17d4da56 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -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" => "Анімація", @@ -711,9 +708,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", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index df76b4a93..9063b7d76 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d6dd0eabd..a95f7684d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -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" => "动画", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c1f894ea9..e542e627b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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 = 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 = 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, } 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 { } } +/// 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) { + 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 }; diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 2a2509f08..f2d9fc6e8 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -242,6 +242,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) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5dfe7b2f5..403bc548c 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1658,6 +1658,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", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db9ab316f..bdf41f88e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 49aba2b0e..b3ce95182 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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::>(); + assert!(mac_only.is_empty(), "Command-key glyph in ribbon text: {mac_only:?}"); + } +} diff --git a/crates/ui-egui/src/ribbon/formulas.rs b/crates/ui-egui/src/ribbon/formulas.rs index 4f757bdac..464baf4fd 100644 --- a/crates/ui-egui/src/ribbon/formulas.rs +++ b/crates/ui-egui/src/ribbon/formulas.rs @@ -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); + } + } +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 72fce56cb..347fec4fb 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -469,7 +469,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( @@ -562,20 +562,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)))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1552db2bb..a2e749921 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,6 +183,12 @@ fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc], /// 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, 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); } diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index e2fc537ac..d7447dce3 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -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()); diff --git a/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs new file mode 100644 index 000000000..917f36642 --- /dev/null +++ b/crates/ui-egui/tests/it/function_library.rs @@ -0,0 +1,171 @@ +//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions +//! cascade, the AutoSum big split button and the Recently Used keytip. + +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 { + 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(")); +} diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 810338a48..0b8f0fec4 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1764,6 +1764,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()); @@ -2754,7 +2762,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 365; + const EXPECTED: usize = 366; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 302bc9c49..4013f0b4a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -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; diff --git a/crates/ui-egui/tests/it/ribbon_layout.rs b/crates/ui-egui/tests/it/ribbon_layout.rs index faa51b15f..452f8ea33 100644 --- a/crates/ui-egui/tests/it/ribbon_layout.rs +++ b/crates/ui-egui/tests/it/ribbon_layout.rs @@ -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")); diff --git a/docs/architecture.md b/docs/architecture.md index edf1bb679..67bf839f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 (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,19 @@ 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. - **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, @@ -159,6 +165,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) | | 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e1a98f47..630743d03 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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; 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; Recently Used lists what `formulas.recentlyUsed` returns | | 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 | @@ -194,6 +195,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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 |