diff --git a/Cargo.lock b/Cargo.lock
index 5da7c781d..80cf2de70 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1937,7 +1937,7 @@ dependencies = [
[[package]]
name = "gridcraft"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1964,7 +1964,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1976,7 +1976,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1987,7 +1987,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1999,7 +1999,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"chrono",
"js-sys",
@@ -2010,7 +2010,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -2032,7 +2032,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -2041,7 +2041,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -2049,7 +2049,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -2058,7 +2058,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2070,18 +2070,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
-version = "0.14.2"
+version = "0.15.0"
[[package]]
name = "gridcraft-ui-egui"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -2106,7 +2106,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -2123,7 +2123,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"aes",
"gridcraft-core",
@@ -6404,7 +6404,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.14.2"
+version = "0.15.0"
dependencies = [
"gridcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index a43312950..0ce2e8f0b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
-version = "0.14.2"
+version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
diff --git a/README.md b/README.md
index d9dc5eb36..f2d9a930e 100644
--- a/README.md
+++ b/README.md
@@ -298,7 +298,15 @@ stand on its own.
| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
-| | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
+| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
+| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
+| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
+| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
+| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
+| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
+| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
+| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
diff --git a/ROADMAP.md b/ROADMAP.md
index 0dd4affda..3674b593c 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-11-01 · **Change:** minor (Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells stored and kept in files; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -136,6 +136,11 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-01 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
+| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
+| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
+| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
+| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
+| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
@@ -168,6 +173,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
+| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
@@ -246,6 +252,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Progress log: Python cells as data |
+| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
+| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
+| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
+| 2026-10-31 | minor | Progress log: Function Library |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
@@ -264,6 +274,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
+| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml
index e5ad73da4..3082080e7 100644
--- a/apps/gridcraft/Cargo.toml
+++ b/apps/gridcraft/Cargo.toml
@@ -52,6 +52,10 @@ muda = { version = "0.21", default-features = false }
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
+# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda,
+# winit); pinned exactly to the same versions.
+objc2 = "=0.6.5"
+objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
# pinned exactly to the same versions.
diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs
index 4d596f957..b0898eced 100644
--- a/apps/gridcraft/src/apple_events.rs
+++ b/apps/gridcraft/src/apple_events.rs
@@ -11,6 +11,8 @@
use fmv_macos_events::{Event, Inbox, Registration};
use gridcraft_ui_egui::SheetApp;
+use objc2::MainThreadMarker;
+use objc2_app_kit::{NSApplication, NSRequestUserAttentionType};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
@@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
}
}
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
- Event::Quit => app.request_quit(ctx),
+ Event::Quit => {
+ app.request_quit(ctx);
+ if app.dialog.is_some() {
+ bring_app_forward();
+ }
+ }
}
}
}
+
+/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to
+/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and
+/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock
+/// icon until the user switches over.
+fn bring_app_forward() {
+ let Some(mtm) = MainThreadMarker::new() else { return };
+ let ns_app = NSApplication::sharedApplication(mtm);
+ ns_app.unhide(None);
+ for window in ns_app.windows().to_vec() {
+ if window.isMiniaturized() {
+ window.deminiaturize(None);
+ }
+ }
+ #[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version.
+ ns_app.activateIgnoringOtherApps(true);
+ if !ns_app.isActive() {
+ ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest);
+ }
+}
diff --git a/contributors/people.toml b/contributors/people.toml
index 0e5b14ca4..00f6ce76a 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -106,3 +106,6 @@ sync_github_name = true
[people.Plutonium-fream]
sync_github_name = true
+
+[people.maxm11]
+sync_github_name = true
diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs
index ab6ac1b06..67662c83c 100644
--- a/crates/calc/src/eval/array.rs
+++ b/crates/calc/src/eval/array.rs
@@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
+/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above
+/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap.
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option {
- rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
+ let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?;
+ if cells > MAX_CELLS {
+ return None;
+ }
+ usize::try_from(cells).ok()
}
fn broadcast_dim(a: usize, b: usize) -> usize {
@@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
pub(super) fn single(v: Value) -> Option {
match v {
- Value::Array(a) if a.rows * a.cols > 1 => None,
+ Value::Array(a) if a.data.len() > 1 => None,
v => Some(v.scalar()),
}
}
@@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{MAX_CELLS, broadcast_len};
+
+ #[test]
+ fn broadcast_len_caps_at_max_cells() {
+ assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize));
+ assert_eq!(broadcast_len(4_000, 4_001), None);
+ assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize));
+ }
+
+ /// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a
+ /// `> MAX_CELLS` test would let through; the guard must reject the true product.
+ #[test]
+ fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() {
+ assert_eq!(65_536u32.wrapping_mul(65_536), 0);
+ assert_eq!(broadcast_len(65_536, 65_536), None);
+ assert_eq!(broadcast_len(1_048_576, 16_384), None);
+ }
+}
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 4ca0469f4..d63590737 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -1251,6 +1251,25 @@ fn array_broadcast_result_is_capped() {
assert_eq!(t.num("C3"), 5.0);
}
+/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the
+/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in
+/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at
+/// 16384 columns, so the widest product it can build is 16384 squared).
+#[test]
+fn map_caps_its_result_size() {
+ let mut t = T::new();
+ for (at, formula) in
+ [("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")]
+ {
+ t.set(at, formula);
+ assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
+ }
+ // Within the cap the broadcast shape is unchanged.
+ t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
+ t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
+ assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0));
+}
+
#[test]
fn text_function() {
let mut t = T::new();
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index f2972be5a..920e5f8cd 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -20,7 +20,7 @@ mod paste_special;
mod selection;
use cell::{cell_set, range_fill, range_set_values};
-pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula};
+pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
#[cfg(test)]
pub(crate) use clipboard::parse_tsv;
use clipboard::{clear_clipboard, copy, cut, paste};
@@ -46,7 +46,7 @@ pub fn specs() -> Vec {
"Set Values",
[],
None,
- "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
+ "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
has_doc,
range_set_values
),
@@ -183,6 +183,8 @@ fn undo(s: &mut Session, p: &Json) -> Result {
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
+ // Step by step: a rename and a new sheet undone together match up only one step apart.
+ super::extra::keep_view_modes(d, &cur_wb);
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
@@ -203,6 +205,7 @@ fn redo(s: &mut Session, p: &Json) -> Result {
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
+ super::extra::keep_view_modes(d, &cur_wb);
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs
index fe7d03d51..610135682 100644
--- a/crates/engine/src/cmd/edit/cell.rs
+++ b/crates/engine/src/cmd/edit/cell.rs
@@ -8,6 +8,16 @@ use super::{
/// Typing `=PY(` doesn't make a Python cell; this says what does.
pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…).";
+/// 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.
@@ -21,11 +31,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, py: None }));
}
- 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 == '('));
+ let is_formula = is_formula_input(input);
if is_formula && gridcraft_model::python::is_py_formula(input) {
return Err(EngineError::Other(PY_FORMULA.into()));
}
@@ -189,6 +195,22 @@ pub(crate) fn json_to_input(v: &Json) -> String {
}
}
+/// How many converted strings `range.setValues` reports back, and how much of each input and
+/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data
+/// in the result.
+const MAX_REPORTED_CONVERSIONS: usize = 20;
+const MAX_REPORTED_CHARS: usize = 32;
+
+/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was
+/// cut. Counting characters keeps a multi-byte input whole.
+fn clipped(text: &str) -> String {
+ let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect();
+ if out.len() < text.len() {
+ out.push('…');
+ }
+ out
+}
+
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
@@ -206,6 +228,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result {
return Err(bad("range.setValues", "too many rows"));
}
let mut count = 0;
+ // The strings typed input read as something other than the text given, for the result: a
+ // caller can notice a "05.04.2026" that became a date without reading the file back (#2255).
+ let mut converted: Vec<(CellRef, String)> = Vec::new();
+ let mut converted_count = 0;
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
let cols: Vec<&Json> = match row.as_array() {
@@ -232,15 +258,35 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result {
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
- let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
- // Line breaks still turn on Wrap Text, as typed input does.
- if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
- style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
+ if text.is_empty() {
+ // An empty string clears the cell, as `null` and the default path do:
+ // an empty text cell is not blank, and a script clearing cells with
+ // `""` would leave cells `COUNTA` counts. The cell keeps its formatting.
+ input_to_cell("", sheet, at, &mut cx.wb)?
+ } else {
+ let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
+ // Line breaks still turn on Wrap Text, as typed input does.
+ if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
+ style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
+ }
+ Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
}
- Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
+ // A string that became a formula was written as one (`=`), which is deliberate;
+ // only a number (dates and times included), boolean or error is a reading of the
+ // string and worth reporting.
+ if let Some(c) = &cell
+ && matches!(v, Json::String(_))
+ && c.formula.is_none()
+ && matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_))
+ {
+ converted_count += 1;
+ if converted.len() < MAX_REPORTED_CONVERSIONS {
+ converted.push((at, input.clone()));
+ }
+ }
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
@@ -255,7 +301,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result {
}
Ok(())
})?;
- Ok(json!({"cells": count}))
+ let d = s.doc()?;
+ let reported: Vec =
+ d.wb.sheet(sheet)
+ .map(|sh| {
+ converted
+ .iter()
+ .map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))}))
+ .collect()
+ })
+ .unwrap_or_default();
+ Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported}))
}
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result {
diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs
index c9e1e7a93..3f3a2fb6d 100644
--- a/crates/engine/src/cmd/edit/tests.rs
+++ b/crates/engine/src/cmd/edit/tests.rs
@@ -192,6 +192,74 @@ fn set_values_can_store_strings_as_the_text_they_are() {
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
+/// The result names the strings typed input read as something else, so a script notices without
+/// reading the file back (#2255). A formula and a leading apostrophe are not conversions.
+#[test]
+fn set_values_reports_the_strings_typed_input_read() {
+ let mut s = Session::new();
+ s.new_workbook();
+ let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]);
+ let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
+ assert_eq!(r["cells"], 6, "{r}");
+ assert_eq!(r["convertedCount"], 4, "{r}");
+ assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}");
+ assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}");
+ assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}");
+ assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}");
+ // The report is a diagnostic, not a second copy of the data: a long input is cut.
+ let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap();
+ assert_eq!(long["convertedCount"], 1, "{long}");
+ let input = long["converted"][0]["input"].as_str().unwrap_or_default();
+ assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}");
+ assert!(input.ends_with('…'), "{input}");
+ // Nothing is read with `parse: false`, so nothing is reported.
+ let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap();
+ assert_eq!(verbatim["convertedCount"], 0, "{verbatim}");
+ assert_eq!(verbatim["converted"], json!([]), "{verbatim}");
+}
+
+/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a
+/// script clearing cells with "" must not leave cells `COUNTA` counts.
+#[test]
+fn set_values_parse_false_clears_an_empty_string() {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap();
+ s.execute("home.bold", json!({"range": "A1"})).unwrap();
+ s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap();
+ let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
+ assert_eq!(get(&mut s, "A1")["type"], "empty");
+ assert_eq!(get(&mut s, "B1")["type"], "empty");
+ let d = s.doc().unwrap();
+ let sh = d.wb.sheet(0).unwrap();
+ assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting");
+ assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0));
+}
+
+/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the
+/// default's reading of a date survives as a date (#2255).
+#[test]
+fn set_values_text_survives_an_xlsx_roundtrip() {
+ let mut s = Session::new();
+ s.new_workbook();
+ let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]);
+ s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap();
+ s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap();
+ let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
+ let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
+ let sh = wb.sheet(0).unwrap();
+ for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] {
+ assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given");
+ }
+ assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula");
+ assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0));
+ assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true));
+ // The default read the date, and the file keeps it as one.
+ let at = CellRef::new(2, 0);
+ assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026");
+ assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy");
+}
+
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs
index c9a1582a2..fd2a6e7a2 100644
--- a/crates/engine/src/cmd/extra.rs
+++ b/crates/engine/src/cmd/extra.rs
@@ -28,7 +28,7 @@ use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}
pub(crate) use scripts::has_scripts;
use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts};
use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window};
-pub(crate) use window::{keep_watch_list, window_param};
+pub(crate) use window::{keep_view_modes, keep_watch_list, window_param};
pub fn specs() -> Vec {
vec![
@@ -310,7 +310,7 @@ pub fn specs() -> Vec {
new_cell_style
),
cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))),
- cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used),
+ cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used),
cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))),
cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))),
cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))),
diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs
index ae1ec8cbb..838ce8342 100644
--- a/crates/engine/src/cmd/extra/cells.rs
+++ b/crates/engine/src/cmd/extra/cells.rs
@@ -123,25 +123,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result {
})
}
+/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
+/// ([`crate::Prefs::recent_functions`]).
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result {
- let mut seen: Vec = Vec::new();
- for (id, p) in s.journal.iter().rev() {
- if id != "cell.set" {
- continue;
- }
- let input = p.get("input").and_then(Json::as_str).unwrap_or("");
- if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
- for f in e.functions() {
- if !seen.iter().any(|x| x == f) {
- seen.push(f.to_string());
- }
- }
- }
- if seen.len() >= 10 {
- break;
- }
- }
- Ok(json!(seen))
+ Ok(json!(s.prefs.recent_functions))
}
pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result {
diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs
index b20a00a61..b8129805f 100644
--- a/crates/engine/src/cmd/extra/window.rs
+++ b/crates/engine/src/cmd/extra/window.rs
@@ -38,6 +38,35 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc
wb.names.extend(keep);
}
+/// Normal, Page Layout and Page Break Preview belong to the window, not to the edits: Excel doesn't
+/// undo a switch between them, and `set_view` is no undo step. So undo and redo keep each sheet's
+/// current view when they swap in another snapshot of the workbook, and a view kept this way
+/// doesn't make a saved workbook dirty.
+pub(crate) fn keep_view_modes(d: &mut crate::DocState, now: &Workbook) {
+ // Sheets have no stable id. A name missing from `now` is a rename being undone or redone, which
+ // keeps the sheet count and position: the sheet is the one at the same index, under a name the
+ // restored snapshot doesn't have.
+ let renamed = |i: usize| {
+ (now.sheets.len() == d.wb.sheets.len()).then(|| now.sheets.get(i)).flatten().filter(|sh| !d.wb.sheets.iter().any(|s| s.name == sh.name))
+ };
+ let current = |i: usize, name: &str| now.sheets.iter().find(|sh| sh.name == name).or_else(|| renamed(i)).map(|sh| sh.view);
+ let changed: Vec<(usize, SheetViewMode)> =
+ d.wb.sheets.iter().enumerate().filter_map(|(i, sh)| current(i, &sh.name).filter(|v| *v != sh.view).map(|v| (i, v))).collect();
+ if changed.is_empty() {
+ return;
+ }
+ let dirty = d.is_dirty();
+ let wb = std::sync::Arc::make_mut(&mut d.wb);
+ for (i, v) in changed {
+ if let Some(sh) = wb.sheet_mut(i) {
+ sh.view = v;
+ }
+ }
+ if !dirty {
+ d.saved = d.wb.clone();
+ }
+}
+
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result {
let d = s.doc()?;
let mut views = stash_map(&d.wb, VIEWS_NAME);
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index b9b97af83..ce1fb40b0 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -282,7 +282,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
- let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
+ // Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
+ let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| {
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
_ => format!("We can't open '{name}': {e}"),
@@ -311,17 +312,17 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
- let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
+ let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
rename_first_sheet(&mut wb, name);
- Ok((wb, vec![]))
+ Ok((wb, report.warnings))
}
_ => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
- let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
+ let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
- Ok((wb, vec![]))
+ Ok((wb, report.warnings))
}
}
}
diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs
index 775c59eed..9051c5c09 100644
--- a/crates/engine/src/io/tests.rs
+++ b/crates/engine/src/io/tests.rs
@@ -291,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() {
assert_eq!(wb.sheets[0].name, "Sales");
}
+#[test]
+fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() {
+ let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap();
+ let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap();
+ for file in ["dates.csv", "dates.txt"] {
+ let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap();
+ assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}");
+ assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}");
+ assert!(warnings.is_empty(), "{file}: {warnings:?}");
+ }
+ let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap();
+ assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}");
+}
+
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
@@ -561,3 +575,32 @@ fn content_wins_over_a_mismatched_extension() {
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
}
}
+
+/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)`
+/// wrapper as the formula inside when GridCraft calculates every function it calls (including
+/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a
+/// Google-only function.
+#[test]
+fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
+ use gridcraft_core::{CellRef, Value};
+ let mut wb = Workbook::new();
+ let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]);
+ let wrapped = [
+ ("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)),
+ ("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")),
+ ];
+ for (at, text, value) in &wrapped {
+ let cell = gridcraft_model::Cell {
+ value: value.clone(),
+ formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))),
+ ..Default::default()
+ };
+ sh.cells.set(CellRef::parse(at).unwrap(), cell);
+ }
+ let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
+ let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap();
+ let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
+ assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))"));
+ assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
+ assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 3ae4ac95a..4ea043a68 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -20,6 +20,7 @@ pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
+pub mod recent;
pub mod record;
pub mod sample;
pub mod selection;
@@ -390,6 +391,10 @@ pub struct Prefs {
pub formula_bar: bool,
/// The statistics the status bar shows for a selection (right-click the status bar).
pub status_bar: StatusBarStats,
+ /// Formulas › Recently Used: the functions most recently entered in a formula, newest first
+ /// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel.
+ #[serde(deserialize_with = "recent::lenient")]
+ pub recent_functions: Vec,
}
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
@@ -424,6 +429,7 @@ impl Default for Prefs {
user_dictionary: vec![],
formula_bar: true,
status_bar: StatusBarStats::default(),
+ recent_functions: recent::defaults(),
}
}
}
@@ -558,6 +564,17 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
+ /// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit
+ /// is allowed. Front ends ask before opening an editor, so typing into a locked cell never
+ /// leaves a draft that can't be entered (and so blocks saving).
+ pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option {
+ let d = self.active()?;
+ let sh = d.wb.sheet(sheet)?;
+ if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked {
+ return None;
+ }
+ cmd::locked_cell_error(sh, at, &d.unlocked_ranges)
+ }
/// Opens a workbook in a new window, which becomes the active one.
pub fn add_document(&mut self, d: DocState) -> usize {
let uid = d.uid;
@@ -790,6 +807,7 @@ impl Session {
}
if self.journal_paused == 0 {
self.push_journal(id, cmd::journal_params(¶ms));
+ recent::note(&mut self.prefs.recent_functions, id, ¶ms);
}
}
// Inside an undo group the group owns the step. A command that ended a group open before
diff --git a/crates/engine/src/recent.rs b/crates/engine/src/recent.rs
new file mode 100644
index 000000000..0a1bba2dc
--- /dev/null
+++ b/crates/engine/src/recent.rs
@@ -0,0 +1,205 @@
+//! Formulas › Recently Used: the functions most recently entered in a formula.
+//!
+//! The list is [`crate::Prefs::recent_functions`], an application list like Excel's, saved with
+//! the other preferences. [`crate::Session::execute`] calls [`note`] for each `cell.*` and
+//! `range.*` command it journals, from any source (the interface, the control channel, MCP or a
+//! script), so the list never depends on the journal, which drops its oldest entries.
+
+use std::collections::HashSet;
+use std::sync::OnceLock;
+
+use crate::cmd::edit::is_formula_input;
+use gridcraft_formula::lexer::{Tok, tokenize};
+use gridcraft_formula::normalize_function_name;
+use serde::Deserialize;
+use serde_json::Value as Json;
+
+/// How many functions the list keeps.
+pub const MAX: usize = 10;
+/// The longest formula read (Excel's formula length limit): a longer one was not typed.
+const FORMULA_MAX: usize = 8_192;
+/// The most formulas read from one command: a large paste needs only its last few.
+const SCAN_BUDGET: usize = 2_000;
+
+/// What the list holds before any function has been used.
+pub fn defaults() -> Vec {
+ ["SUM", "AVERAGE", "IF", "COUNT", "MAX", "MIN", "SUMIF", "XLOOKUP", "PMT", "TODAY"].map(String::from).to_vec()
+}
+
+/// Whether `name` (upper case, without `_xlfn.`) is a function of the catalog.
+fn known(name: &str) -> bool {
+ static NAMES: OnceLock> = OnceLock::new();
+ NAMES
+ .get_or_init(|| crate::cmd::formulas::function_list().iter().filter_map(|f| f["name"].as_str().map(str::to_ascii_uppercase)).collect())
+ .contains(name)
+}
+
+/// The functions a formula calls, outermost first, known functions only. A formula is what a
+/// cell takes as one (`=…`, or `+…`/`-…` as Excel takes `+SUM(1)`).
+pub fn functions_in_formula(text: &str) -> Vec {
+ if !is_formula_input(text) {
+ return vec![];
+ }
+ let body = text.strip_prefix('=').unwrap_or(text);
+ if body.len() > FORMULA_MAX {
+ return vec![];
+ }
+ let mut out: Vec = Vec::new();
+ for t in tokenize(body).unwrap_or_default() {
+ if let Tok::Func(name) = t.tok {
+ let name = normalize_function_name(&name);
+ if known(&name) && !out.contains(&name) {
+ out.push(name);
+ }
+ }
+ }
+ out
+}
+
+/// The formula texts a command's params enter, the last first, at most `budget` of them.
+fn formulas_newest_first<'a>(id: &str, params: &'a Json, budget: usize) -> Vec<&'a str> {
+ fn walk<'a>(v: &'a Json, out: &mut Vec<&'a str>, depth: usize, budget: usize) {
+ match v {
+ Json::String(s) if is_formula_input(s) && out.len() < budget => out.push(s),
+ // A range's values are rows of cells; anything deeper is not a cell value.
+ Json::Array(a) if depth < 2 => {
+ for x in a.iter().rev() {
+ if out.len() >= budget {
+ return;
+ }
+ walk(x, out, depth + 1, budget);
+ }
+ }
+ _ => {}
+ }
+ }
+ let mut out = Vec::new();
+ if id.starts_with("cell.") || id.starts_with("range.") {
+ for key in ["formula", "values", "value", "input"] {
+ if let Some(v) = params.get(key) {
+ walk(v, &mut out, 0, budget);
+ }
+ }
+ }
+ out
+}
+
+/// Moves `names` (most recent first) to the front of `recent`, keeping at most [`MAX`].
+pub fn push(recent: &mut Vec, names: &[String]) {
+ let mut next: Vec = Vec::with_capacity(MAX);
+ for n in names.iter().chain(recent.iter()) {
+ if next.len() == MAX {
+ break;
+ }
+ if !next.contains(n) {
+ next.push(n.clone());
+ }
+ }
+ *recent = next;
+}
+
+/// Moves the functions that the formulas a command entered call to the front of `recent`. The
+/// last formulas are read first (they are the most recent), within [`SCAN_BUDGET`].
+pub(crate) fn note(recent: &mut Vec, id: &str, params: &Json) {
+ let mut names: Vec = Vec::new();
+ for f in formulas_newest_first(id, params, SCAN_BUDGET) {
+ for n in functions_in_formula(f) {
+ if !names.contains(&n) {
+ names.push(n);
+ }
+ }
+ if names.len() >= MAX {
+ break;
+ }
+ }
+ if !names.is_empty() {
+ push(recent, &names);
+ }
+}
+
+/// Reads a saved list leniently: known function names only, at most [`MAX`]. A value that is not
+/// a list gives the default list, so a hand-edited or hostile `prefs.json` loses this list, never
+/// the other preferences.
+pub(crate) fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> {
+ let raw = Json::deserialize(d)?;
+ let Some(list) = raw.as_array() else { return Ok(defaults()) };
+ let mut out = Vec::new();
+ for name in list.iter().filter_map(Json::as_str) {
+ if out.len() == MAX {
+ break;
+ }
+ let name = name.to_ascii_uppercase();
+ if known(&name) && !out.contains(&name) {
+ out.push(name);
+ }
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn formulas_name_their_functions_outermost_first() {
+ assert_eq!(functions_in_formula("=IF(SUM(A1:A3)>2,_xlfn.XLOOKUP(1,B:B,C:C),sum(1))"), ["IF", "SUM", "XLOOKUP"]);
+ assert!(functions_in_formula("=MYLAMBDA(1)+A1").is_empty(), "only known functions");
+ assert!(functions_in_formula("SUM(1)").is_empty(), "not a formula");
+ assert!(functions_in_formula("=\"SUM(\"").is_empty(), "inside a string");
+ assert!(functions_in_formula(&format!("=SUM({})", "1+".repeat(9000))).is_empty(), "too long");
+ assert!(functions_in_formula("=((((").is_empty());
+ assert!(functions_in_formula("=").is_empty());
+ // A formula may be typed with + or - instead of =, as input_to_cell takes it.
+ assert_eq!(functions_in_formula("+SUM(1)"), ["SUM"]);
+ assert_eq!(functions_in_formula("-ABS(A1)"), ["ABS"]);
+ assert!(functions_in_formula("-5").is_empty() && functions_in_formula("+1e3").is_empty(), "numbers");
+ assert!(functions_in_formula("+").is_empty() && functions_in_formula("- SUM(1)").is_empty(), "not formulas");
+ }
+
+ #[test]
+ fn commands_give_their_formulas_newest_first() {
+ let read = |id: &str, params: Json| formulas_newest_first(id, ¶ms, SCAN_BUDGET).into_iter().map(String::from).collect::>();
+ assert_eq!(read("range.setValues", json!({"values": [["=SUM(1)", 2], ["x", "=PMT(1,2,3)"]]})), ["=PMT(1,2,3)", "=SUM(1)"]);
+ assert_eq!(read("cell.set", json!({"input": "=NPV(1,2)"})), ["=NPV(1,2)"]);
+ assert_eq!(read("range.setValues", json!({"values": [["+SUM(1)", "-5", "-ABS(2)"]]})), ["-ABS(2)", "+SUM(1)"]);
+ assert!(read("home.bold", json!({"input": "=IRR(1)"})).is_empty(), "only cell.* and range.* enter formulas");
+ assert!(read("range.setValues", json!({"values": [[["=FV(1,2,3)"]]]})).is_empty(), "deeper than rows of cells");
+ // A large paste is read from its end, within the budget.
+ let rows: Vec = (0..5_000).map(|i| json!([format!("=ABS({i})")])).collect();
+ let paste = json!({ "values": rows });
+ let last = formulas_newest_first("range.setValues", &paste, 3);
+ assert_eq!(last, ["=ABS(4999)", "=ABS(4998)", "=ABS(4997)"]);
+ }
+
+ #[test]
+ fn recent_keeps_ten_distinct_newest_first() {
+ let mut r = defaults();
+ assert!(r.iter().all(|n| known(n)), "the default list names known functions");
+ push(&mut r, &["PMT".into(), "NPV".into()]);
+ assert_eq!(r.len(), MAX);
+ assert_eq!(&r[..3], ["PMT", "NPV", "SUM"]);
+ assert_eq!(r.iter().filter(|n| *n == "PMT").count(), 1);
+ let many: Vec =
+ ["ABS", "ACOS", "AND", "CHOOSE", "DATE", "EXP", "FIND", "INDEX", "LEFT", "LN", "MOD", "NOW"].map(String::from).to_vec();
+ push(&mut r, &many);
+ assert_eq!(r, many[..MAX]);
+ }
+
+ #[test]
+ fn a_saved_list_is_read_leniently() {
+ let prefs = |v: Json| serde_json::from_value::(v).unwrap();
+ assert_eq!(prefs(json!({"r1c1": true})).recent_functions, defaults(), "a prefs.json from before the list gets the default");
+ // A hostile value loses only itself, never the other preferences.
+ let bad = prefs(json!({"r1c1": true, "recentFunctions": 5}));
+ assert!(bad.r1c1);
+ assert_eq!(bad.recent_functions, defaults());
+ let mut odd: Vec = ["npv", "NOT_A_FUNCTION", "SUM", "NPV"].iter().map(|n| json!(n)).chain([json!(3), json!(null)]).collect();
+ odd.extend(crate::cmd::formulas::function_list().into_iter().take(40).map(|f| f["name"].clone()));
+ let odd = prefs(json!({ "recentFunctions": odd }));
+ assert_eq!(&odd.recent_functions[..2], ["NPV", "SUM"]);
+ assert_eq!(odd.recent_functions.len(), MAX);
+ let back = prefs(serde_json::to_value(&odd).unwrap());
+ assert_eq!(back.recent_functions, odd.recent_functions, "the list survives a save and reload");
+ }
+}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 86e8645d3..475d0d54f 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1400,6 +1400,17 @@ fn protection_refuses_edits_of_locked_cells() {
assert_eq!(v(&s, "A1"), Value::Number(1.0));
}
+#[test]
+fn locked_cell_message_matches_what_an_edit_would_be_refused_with() {
+ let s = protected(json!({}));
+ let locked = CellRef::parse("A1").unwrap();
+ let unlocked = CellRef::parse("D1").unwrap();
+
+ assert!(s.locked_cell_message(0, locked).is_some_and(|m| m.contains("protected sheet")));
+ assert_eq!(s.locked_cell_message(0, unlocked), None, "an unlocked cell stays editable");
+ assert_eq!(s.locked_cell_message(9, locked), None, "no such sheet");
+}
+
#[test]
fn protection_allows_permitted_row_and_column_edits() {
let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true}));
@@ -2403,6 +2414,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() {
}
}
+#[test]
+fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() {
+ let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 };
+ for (initial_active, command, params, expected_order, expected_active) in [
+ ("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1),
+ ("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1),
+ ("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0),
+ ("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0),
+ ("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2),
+ ] {
+ let mut session = s();
+ session.execute("sheet.rename", json!({"name": "First"})).unwrap();
+ for name in ["Second", "Third"] {
+ session.execute("home.insertSheet", json!({"name": name})).unwrap();
+ }
+ std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view;
+ session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap();
+ session.execute(command, params).unwrap();
+
+ let wb = &session.doc().unwrap().wb;
+ assert_eq!(wb.active_sheet(), expected_active, "{command}");
+ assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), expected_order, "{command}");
+ let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
+ assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::>(), expected_order, "{command} after reopening");
+ assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}");
+ assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index");
+ assert_eq!(back.view, view, "workbook display settings survive {command}");
+ }
+}
+
#[test]
fn xlsx_roundtrip_through_engine() {
let mut s = s();
@@ -5237,3 +5278,72 @@ fn unique_nonce() -> u64 {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
+
+/// Normal, Page Layout and Page Break Preview are not undo steps (as in Excel), so undoing or
+/// redoing an edit keeps each sheet in the view it is in now, instead of putting back the view
+/// of the snapshot it swaps in. Found through the command-sequence fuzz (seed 177).
+#[test]
+fn undo_and_redo_keep_each_sheets_view() {
+ use gridcraft_model::SheetViewMode;
+ let view = |s: &Session, i: usize| s.doc().unwrap().wb.sheet(i).unwrap().view;
+ let mut s = s();
+ s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
+ s.execute("view.pageBreakPreview", json!({})).unwrap();
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(v(&s, "A1"), Value::Empty);
+ assert_eq!(view(&s, 0), SheetViewMode::PageBreakPreview, "undo leaves the view alone");
+ s.execute("view.pageLayout", json!({})).unwrap();
+ s.execute("edit.redo", json!({})).unwrap();
+ assert_eq!(v(&s, "A1"), Value::Number(1.0));
+ assert_eq!(view(&s, 0), SheetViewMode::PageLayout, "redo leaves the view alone");
+
+ // Undoing a rename: the sheet is found at its position under the restored name.
+ s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
+ s.execute("view.normal", json!({})).unwrap();
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_ne!(s.doc().unwrap().wb.sheet(0).unwrap().name, "Renamed");
+ assert_eq!(view(&s, 0), SheetViewMode::Normal);
+
+ // Undoing a new sheet: the other sheets keep theirs.
+ s.execute("view.pageBreakPreview", json!({})).unwrap();
+ s.execute("home.insertSheet", json!({"name": "Extra"})).unwrap();
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().wb.sheets.len(), 1);
+ assert_eq!(view(&s, 0), SheetViewMode::PageBreakPreview);
+}
+
+#[test]
+fn a_multi_step_undo_keeps_views_through_each_step() {
+ // A rename and a new sheet undone in one call: neither the name nor the sheet count matches
+ // between the first and last workbook, so the view is carried step by step.
+ let mut s = s();
+ let first = s.doc().unwrap().wb.sheet(0).unwrap().name.clone();
+ s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
+ s.execute("view.pageBreakPreview", json!({"sheet": 0})).unwrap();
+ s.execute("home.insertSheet", json!({"name": "Extra"})).unwrap();
+ s.execute("edit.undo", json!({"steps": 2})).unwrap();
+ let d = s.doc().unwrap();
+ assert_eq!(d.wb.sheets.len(), 1);
+ assert_eq!(d.wb.sheet(0).unwrap().name, first);
+ assert_eq!(d.wb.sheet(0).unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
+ s.execute("edit.redo", json!({"steps": 2})).unwrap();
+ let d = s.doc().unwrap();
+ assert_eq!((d.wb.sheets.len(), d.wb.sheet(0).unwrap().name.as_str()), (2, "Renamed"));
+ assert_eq!(d.wb.sheet(0).unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
+}
+
+#[test]
+fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() {
+ let mut s = s();
+ s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
+ {
+ let d = s.doc_mut().unwrap();
+ d.saved = d.wb.clone();
+ }
+ s.execute("edit.undo", json!({})).unwrap();
+ s.execute("view.pageBreakPreview", json!({})).unwrap();
+ s.execute("edit.redo", json!({})).unwrap();
+ let d = s.doc().unwrap();
+ assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
+ assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change");
+}
diff --git a/crates/engine/tests/it/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs
index 3c241995e..c32190dcd 100644
--- a/crates/engine/tests/it/command_sequence_fuzz.rs
+++ b/crates/engine/tests/it/command_sequence_fuzz.rs
@@ -34,7 +34,7 @@ use std::sync::Arc;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
use gridcraft_engine::calc::Calc;
-use gridcraft_engine::model::{CalcMode, Formula, Sheet, Workbook};
+use gridcraft_engine::model::{CalcMode, Formula, Sheet, SheetViewMode, Workbook};
use gridcraft_engine::{EngineError, Session, command_specs, find_command, io};
use serde_json::{Value as Json, json};
@@ -491,8 +491,9 @@ fn watch_list(wb: &Workbook) -> Option<&str> {
wb.names.iter().find(|n| n.name == WATCH_NAME).map(|n| n.formula.as_str())
}
-/// The workbook with view state (active sheet, scroll, active cell) and the Watch Window list
-/// reset: undo restores view state as it was, but switching sheets isn't an undoable edit.
+/// The workbook with view state (active sheet, scroll, active cell, Normal / Page Layout / Page
+/// Break Preview) and the Watch Window list reset: undo restores view state as it was, but
+/// switching sheets or views isn't an undoable edit.
fn canonical(wb: &Workbook) -> Workbook {
let mut wb = wb.clone();
wb.set_active_sheet(0);
@@ -501,6 +502,7 @@ fn canonical(wb: &Workbook) -> Workbook {
let sh = Arc::make_mut(sh);
sh.view_active = CellRef::default();
sh.view_top_left = CellRef::default();
+ sh.view = SheetViewMode::Normal;
}
wb
}
@@ -1114,3 +1116,13 @@ fn params_docs_parse_into_keys() {
assert!(find_command(id).is_some(), "CURATED names unknown command `{id}`");
}
}
+
+/// Seed 177: switching a sheet to Page Break Preview isn't an undo step (as in Excel), so undoing
+/// everything leaves the view mode alone and the undo check must not count it as an edit.
+#[test]
+fn view_mode_switch_is_not_an_undo_difference() {
+ for id in ["view.pageBreakPreview", "view.pageLayout"] {
+ let failure = replay("sales", &[(id.to_string(), json!({"sheet": 0}))]);
+ assert!(failure.is_none(), "`{id}`: {failure:?}");
+ }
+}
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
index 20898e152..99ebd42b1 100644
--- a/crates/engine/tests/it/main.rs
+++ b/crates/engine/tests/it/main.rs
@@ -22,6 +22,7 @@ mod percent_entry;
mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
+mod recently_used;
mod row_autofit;
mod sheet_copy_spill;
mod sheet_name_quoting;
diff --git a/crates/engine/tests/it/recently_used.rs b/crates/engine/tests/it/recently_used.rs
new file mode 100644
index 000000000..f40e3c1b0
--- /dev/null
+++ b/crates/engine/tests/it/recently_used.rs
@@ -0,0 +1,58 @@
+//! Formulas › Recently Used: the functions most recently entered in a formula, from any command
+//! that enters one, kept in the preferences rather than read back from the journal.
+
+use gridcraft_engine::Session;
+use serde_json::{Value as Json, json};
+
+fn recent(s: &mut Session) -> Vec {
+ let list = s.execute("formulas.recentlyUsed", json!({})).unwrap();
+ list.as_array().unwrap().iter().filter_map(Json::as_str).map(String::from).collect()
+}
+
+#[test]
+fn formulas_entered_by_any_command_lead_the_list() {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
+ s.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap();
+ s.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap();
+ s.execute("cell.set", json!({"cell": "A3", "input": "PMT is just text"})).unwrap();
+ assert_eq!(&recent(&mut s)[..4], ["FV", "IRR", "ROUND", "NPV"]);
+ // A command that fails enters nothing.
+ assert!(s.execute("cell.set", json!({"cell": "not a cell", "input": "=DB(1,2,3,4)"})).is_err());
+ assert_ne!(recent(&mut s)[0], "DB");
+}
+
+#[test]
+fn formulas_typed_with_a_plus_or_minus_count() {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1", "input": "+ROUND(1.5,0)"})).unwrap();
+ s.execute("cell.set", json!({"cell": "A2", "input": "-SLN(100,10,5)"})).unwrap();
+ s.execute("cell.set", json!({"cell": "A3", "input": "-5"})).unwrap();
+ assert_eq!(&recent(&mut s)[..2], ["SLN", "ROUND"]);
+}
+
+#[test]
+fn the_list_does_not_depend_on_the_journal() {
+ // The journal drops its oldest entries past its caps (and is public); the list stays.
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
+ s.journal.clear();
+ assert_eq!(recent(&mut s)[0], "NPV");
+ s.execute("cell.set", json!({"cell": "A2", "input": "=SLN(100,10,5)"})).unwrap();
+ assert_eq!(&recent(&mut s)[..2], ["SLN", "NPV"]);
+}
+
+#[test]
+fn the_list_is_kept_with_the_preferences() {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1", "input": "=SYD(100,10,5,1)"})).unwrap();
+ let saved = serde_json::to_value(&s.prefs).unwrap();
+ let mut next = Session::new();
+ next.new_workbook();
+ next.prefs = serde_json::from_value(saved).unwrap();
+ assert_eq!(recent(&mut next)[0], "SYD");
+}
diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs
index 61cfc8dfd..71631b5e8 100644
--- a/crates/mcp/src/tests.rs
+++ b/crates/mcp/src/tests.rs
@@ -252,6 +252,22 @@ fn save_and_open_roundtrip() {
let _ = std::fs::remove_file(path);
}
+/// #2255: `parse: false` on `write_range` stores strings as the text they are, so a dd.mm.yyyy
+/// date or a thousands separator survives, and the result names the strings the default read.
+#[test]
+fn write_range_can_store_strings_as_text() {
+ let mut s = server();
+ let row = json!([["05.04.2026", "2.500", "4.25 %", "=1+1"]]);
+ let stored = ok(&mut s, "write_range", json!({"range": "A1", "parse": false, "values": row}));
+ assert_eq!(stored["convertedCount"], 0, "nothing was read: {stored}");
+ let v = ok(&mut s, "read_range", json!({"range": "A1:D1"}));
+ assert_eq!(v["values"], json!([["05.04.2026", "2.500", "4.25 %", 2.0]]), "{v}");
+ // Without it the strings are read like typed input, and the result says which.
+ let read = ok(&mut s, "write_range", json!({"range": "A3", "values": row}));
+ assert_eq!(read["convertedCount"], 3, "{read}");
+ assert_eq!(read["converted"][0], json!({"cell": "A3", "input": "05.04.2026", "stored": "5/4/2026"}), "{read}");
+}
+
/// An agent builds a small monthly budget through MCP tool calls only.
#[test]
fn agent_builds_a_budget() {
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
index b422b5f34..87f7fea5d 100644
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -190,11 +190,13 @@ fn base_tool_definitions() -> Vec {
"Write range",
"Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \
and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \
- input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.",
+ input (\"12%\", \"1/2/2024\", \"$5\") unless `parse` is false, which stores them as the text they are. The result's \
+ `converted` lists the strings that were read as something else. One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.",
obj(
json!({
"range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""),
"values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}},
+ "parse": boolean("Read strings not starting with = like typed input, so \"05.04.2026\" becomes a date (default true); false stores them as the text they are, and \"\" clears the cell"),
}),
&["range", "values"],
),
@@ -637,7 +639,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result
"list_windows" => exec(b, "window.list", json!({})),
"inspect_workbook" => b.call("document.inspect", json!({})),
"read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])),
- "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])),
+ "write_range" => exec(b, "range.setValues", pick(a, &["range", "values", "parse"])),
"set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])),
"get_cell" => exec(b, "cell.get", pick(a, &["cell"])),
"evaluate_formula" => {
diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs
index 47ae6e8db..b9b67f519 100644
--- a/crates/model/src/lib.rs
+++ b/crates/model/src/lib.rs
@@ -29,7 +29,7 @@ pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
VAlign, VertAlign,
};
-pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook};
+pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView};
/// Font used for new workbooks. Only a name: renderers substitute an available open font
/// (e.g. the metric-compatible Carlito) when it isn't installed.
diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs
index 0d82ca6ff..586ed36fa 100644
--- a/crates/model/src/workbook.rs
+++ b/crates/model/src/workbook.rs
@@ -30,6 +30,51 @@ pub enum ShowObjects {
None,
}
+/// The workbook window's display settings from `` (Excel: File › Options ›
+/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs
+/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`,
+/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this
+/// share to 0–1000, including values supplied directly through the model.
+///
+/// The values are modelled so an open + save keeps them — a workbook Excel saved with the
+/// tabs hidden must not come back with them shown and the settings gone. The app does not
+/// act on them yet and there is no Options control for them; that is the other half of
+/// storytold/gridcraft#3057.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorkbookView {
+ #[serde(default = "yes")]
+ pub show_sheet_tabs: bool,
+ #[serde(default = "yes")]
+ pub show_horizontal_scroll: bool,
+ #[serde(default = "yes")]
+ pub show_vertical_scroll: bool,
+ #[serde(default = "default_tab_ratio")]
+ pub tab_ratio: u32,
+}
+
+fn yes() -> bool {
+ true
+}
+
+fn default_tab_ratio() -> u32 {
+ 600
+}
+
+impl Default for WorkbookView {
+ fn default() -> Self {
+ WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: true, show_vertical_scroll: true, tab_ratio: default_tab_ratio() }
+ }
+}
+
+impl WorkbookView {
+ /// The entire bar, in thousandths: the upper bound used by XLSX I/O.
+ pub const MAX_TAB_RATIO: u32 = 1000;
+
+ fn is_default(&self) -> bool {
+ *self == WorkbookView::default()
+ }
+}
+
/// File › Options › Formulas › Calculation options, stored per workbook (``).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CalcSettings {
@@ -150,6 +195,9 @@ pub struct Workbook {
pub show_objects: ShowObjects,
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
pub active_sheet: ActiveSheet,
+ /// The workbook window's display settings (``), kept across an open + save.
+ #[serde(default, skip_serializing_if = "WorkbookView::is_default")]
+ pub view: WorkbookView,
pub props: DocProps,
/// Workbook structure protection.
pub protected_structure: bool,
@@ -284,6 +332,7 @@ impl Workbook {
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
active_sheet: ActiveSheet::default(),
+ view: WorkbookView::default(),
props: DocProps::default(),
protected_structure: false,
structure_password: None,
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 6fa5def4f..068be59a4 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -75,6 +75,7 @@ impl SheetApp {
macros: macro_ui::MacroUi::default(),
started: now_ms(),
allow_close: false,
+ bring_forward: false,
open_switch_windows: false,
ui_window: None,
parked: HashMap::new(),
diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs
index 24889ec82..abb849aba 100644
--- a/crates/ui-egui/src/app/editing.rs
+++ b/crates/ui-egui/src/app/editing.rs
@@ -15,6 +15,13 @@ impl SheetApp {
let Some(d) = self.session.active() else { return };
let Some(sh) = d.wb.active() else { return };
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
+ // A locked cell on a protected sheet can't be entered (Excel's warning, and the engine
+ // refuses `cell.set` anyway): refuse before a draft exists, rather than leaving one that
+ // can never be committed and blocks Save (#2945).
+ if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) {
+ self.message = Some(("GridCraft".into(), m));
+ return;
+ }
// A picture has no editable scalar text. Selecting it, F2, or clicking
// the formula bar must not turn its fallback value into a text edit.
// Typing still starts an intentional replacement.
@@ -103,6 +110,13 @@ impl SheetApp {
}
return true;
}
+ // A draft on a cell sheet protection now locks (the sheet was protected while it was
+ // open) can't be entered: drop it with the warning instead of keeping it, which would
+ // block Save with a draft that can never be committed (#2945).
+ if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) {
+ self.message = Some(("GridCraft".into(), m));
+ return true;
+ }
// All the code deleted: the cell is cleared through cell.set, as an empty entry clears any
// other cell. Code of spaces is still code.
if ed.code_edit && !text.is_empty() {
@@ -241,3 +255,77 @@ impl SheetApp {
self.grid.ensure_visible = true;
}
}
+
+#[cfg(test)]
+mod protected_cell_tests {
+ use super::*;
+ use gridcraft_engine::Session;
+ use gridcraft_engine::core::{CellRef, Value};
+
+ /// A protected sheet whose C2 is unlocked; C2 holds "keep" as the last undo step.
+ fn protected_book() -> SheetApp {
+ let mut session = Session::new();
+ session.new_workbook();
+ let mut app = SheetApp::new(session, Default::default());
+ app.run("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap();
+ app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap();
+ app.run("review.protectSheet", json!({})).unwrap();
+ app
+ }
+
+ fn value(app: &SheetApp, a1: &str) -> Value {
+ let at = CellRef::parse(a1).unwrap();
+ app.session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default()
+ }
+
+ fn protected_message(app: &SheetApp) -> Option<&str> {
+ app.message.as_ref().map(|(_, m)| m.as_str())
+ }
+
+ #[test]
+ fn typing_in_a_locked_cell_does_not_leave_a_draft_that_blocks_undo_and_save() {
+ let mut app = protected_book();
+ assert_eq!(app.session.active().unwrap().selection.active.a1(), "A1", "the locked A1 is the active cell");
+ let undo = app.session.active().unwrap().undo.len();
+ let dirty = app.session.active().unwrap().is_dirty();
+
+ app.begin_edit(Some("locked".into()), false);
+
+ assert!(app.editor.is_none(), "a locked cell must not open an editor");
+ assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet")), "the warning names the protected sheet");
+ assert_eq!(app.session.active().unwrap().undo.len(), undo, "a refused edit adds no undo step");
+ assert_eq!(app.session.active().unwrap().is_dirty(), dirty, "a refused edit leaves the workbook as it was");
+ assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok(), "Save is not blocked by a draft that can never be entered");
+ }
+
+ #[test]
+ fn an_unlocked_cell_on_a_protected_sheet_still_edits_undoes_and_saves() {
+ let mut app = protected_book();
+ app.run("selection.set", json!({"cell": "C2"})).unwrap();
+ app.begin_edit(Some("typed".into()), false);
+ assert!(app.editor.is_some(), "an unlocked cell opens its editor");
+ assert!(app.commit_edit(0, 0, false, false));
+ assert_eq!(value(&app, "C2"), Value::text("typed"));
+
+ assert!(app.run("edit.undo", json!({})).is_ok());
+ assert_eq!(value(&app, "C2"), Value::text("keep"), "Undo of the typed edit still works");
+ assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
+ }
+
+ #[test]
+ fn a_draft_that_becomes_locked_is_cancelled_instead_of_staying_open() {
+ let mut app = protected_book();
+ app.run("selection.set", json!({"cell": "C2"})).unwrap();
+ app.begin_edit(Some("typed".into()), false);
+ // The sheet is unprotected, C2 locked, and the sheet protected again while the draft is open.
+ app.run("review.unprotectSheet", json!({})).unwrap();
+ app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": true}}})).unwrap();
+ app.run("review.protectSheet", json!({})).unwrap();
+
+ assert!(app.commit_edit(0, 0, false, false), "the refused draft is handled, not left open");
+ assert!(app.editor.is_none());
+ assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet")));
+ assert_eq!(value(&app, "C2"), Value::text("keep"));
+ assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
+ }
+}
diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs
index d4479d191..ffa230c34 100644
--- a/crates/ui-egui/src/app/quit.rs
+++ b/crates/ui-egui/src/app/quit.rs
@@ -42,6 +42,7 @@ impl SheetApp {
self.enter_window(window);
let title = self.session.active().map(|d| d.display_title()).unwrap_or_default();
self.open_dialog("saveChanges", json!({"title": title, "quit": true}));
+ self.bring_forward = true;
}
None => {
self.allow_close = true;
diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs
index 6310dd6a0..194533d36 100644
--- a/crates/ui-egui/src/backstage.rs
+++ b/crates/ui-egui/src/backstage.rs
@@ -114,6 +114,8 @@ pub struct Backstage {
pub page: Page,
/// Info: property drafts while a field is being edited (committed when it loses focus).
props: [String; 6],
+ /// Focus at the end of the previous frame, before egui clears it on Esc.
+ text_edit_focused: bool,
/// Print: the whole workbook rather than the active sheet.
pub print_all: bool,
/// Print: fit each sheet on one page.
@@ -138,6 +140,7 @@ impl Backstage {
Backstage {
page,
props: Default::default(),
+ text_edit_focused: false,
print_all: false,
fit_to_page: false,
preview_page: 0,
@@ -160,7 +163,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let Some(mut bs) = app.backstage.take() else { return };
let ctx = ui.ctx().clone();
// Esc inside a text field leaves the field; it closes the view only from outside one.
- let typing = ctx.text_edit_focused();
+ let typing = bs.text_edit_focused;
let t = Tokens::get(&ctx);
let lang = app.ui.language;
let mut out = Outcome::default();
@@ -229,6 +232,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
});
});
});
+ bs.text_edit_focused = ctx.text_edit_focused();
if !typing
&& app.dialog.is_none()
&& app.message.is_none()
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 92187d5c0..2bbb2eba2 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -853,7 +853,13 @@ impl Dialog {
}
d
}
- "insertFunction" => Dialog::custom("insertFunction", "Formula Builder", json!({"category": "Most Recently Used"})),
+ "insertFunction" => {
+ // A Function Library menu opens it on its own category.
+ let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string();
+ let mut d = Dialog::custom("insertFunction", "Insert Function", json!({"category": category}));
+ d.focus_first_field = true;
+ d
+ }
"find" => {
let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"}));
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs
index b662d7197..38a695a80 100644
--- a/crates/ui-egui/src/dialogs/insert_function.rs
+++ b/crates/ui-egui/src/dialogs/insert_function.rs
@@ -1,33 +1,97 @@
-use super::show::ok_cancel;
-use super::{Dialog, Json, SheetApp, vec2};
+use super::show::{ok_cancel, take_first_focus};
+use super::{Dialog, Json, SheetApp, json, vec2};
-/// Insert Function: searches names (as the formula language spells them) and descriptions, shows
-/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula).
+/// Insert Function: search names (as the formula language spells them) and descriptions, or pick
+/// a category (Most Recently Used, All or one category), then a function; each hit shows its
+/// category and description. OK starts editing `=NAME(` (or appends to the formula).
pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
+ use crate::function_library::{self as lib, ALL, MOST_RECENTLY_USED};
let language = app.ui.language;
ui.horizontal(|ui| {
ui.label(language.tr("Search:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date")));
- r.request_focus();
+ // Once only, so Tab can move on to the category list.
+ take_first_focus(d, &r);
});
+ // A new search (typed, or set through the control channel) looks through every function, as
+ // Excel's does.
+ if d.values.get("searched").and_then(Json::as_str) != Some(d.search.as_str()) {
+ d.list_index = 0;
+ if !d.search.trim().is_empty() {
+ d.values.insert("category".into(), json!(ALL));
+ }
+ d.values.insert("searched".into(), json!(d.search));
+ }
+ // The category may be given by label or by engine id (a Function Library menu, `ui.dialog.set`).
+ let saved = d.values.get("category").and_then(Json::as_str).unwrap_or(MOST_RECENTLY_USED).to_string();
+ let mut category = if saved.eq_ignore_ascii_case(ALL) {
+ ALL.to_string()
+ } else {
+ lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string()
+ };
+ // The category list is the dialog's only menu, so a menu open as the frame starts is its own
+ // (Enter on a row picks it and closes the menu in this frame).
+ let menu_was_open = egui::Popup::is_any_open(ui.ctx());
+ let picker = ui.horizontal(|ui| {
+ let label = ui.label(language.tr("Or select a category:"));
+ craft_ui::ComboBox::from_id_salt("insertFunctionCategory")
+ .width(220.0)
+ .selected_text(language.tr(&category))
+ .show_ui(ui, |ui| {
+ for c in [MOST_RECENTLY_USED, ALL].into_iter().chain(lib::CATEGORIES.iter().map(|(_, l)| *l)) {
+ ui.selectable_value(&mut category, c.to_string(), language.tr(c));
+ }
+ })
+ .response
+ .labelled_by(label.id)
+ });
+ // Enter on the category list or in its menu opens it or picks a category, as Space does
+ // (egui clicks a focused control on either key); it does not also confirm the dialog.
+ let picker = picker.inner;
+ if menu_was_open || picker.has_focus() || craft_ui::ComboBox::is_open(ui.ctx(), picker.id) {
+ ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
+ }
+ if category != saved {
+ d.list_index = 0;
+ }
+ d.values.insert("category".into(), json!(category));
let locale = language.formula_locale();
- let list = gridcraft_engine::cmd::formulas::function_list();
// The names and descriptions searched are the ones shown, so in the interface language.
- let q = d.search.to_lowercase();
- let hits: Vec<&Json> = list
- .iter()
- .filter(|f| {
- q.is_empty()
- || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q))
- || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q))
- })
- .take(300)
- .collect();
- egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
- for (i, f) in hits.iter().enumerate() {
- let name = locale.function_name(f["name"].as_str().unwrap_or(""));
- let category = language.tr(function_category_label(f["category"].as_str().unwrap_or("")));
- let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0)));
+ let q = d.search.trim().to_lowercase();
+ let matches = |f: &lib::FnEntry| {
+ q.is_empty()
+ || locale.function_name(&f.name).to_lowercase().contains(&q)
+ || language.function_description(&f.description).unwrap_or(&f.description).to_lowercase().contains(&q)
+ };
+ let hits: Vec<&lib::FnEntry> = if category == MOST_RECENTLY_USED {
+ lib::recent(app).iter().filter_map(|n| lib::find(n)).filter(|f| matches(f)).collect()
+ } else {
+ let id = lib::category_id(&category);
+ // The catalog is fixed (some 500 functions), so All lists every one.
+ lib::all().iter().filter(|f| id.is_none_or(|id| f.category == id) && matches(f)).collect()
+ };
+ let t = crate::theme::Tokens::get(ui.ctx());
+ let font = egui::TextStyle::Body.resolve(ui.style());
+ let rows: Vec<(&str, &str)> = hits.iter().map(|f| (locale.function_name(&f.name), language.tr(lib::category_label(&f.category)))).collect();
+ // The category column starts after the widest name shown, so a long name in any formula
+ // language or font never runs into it.
+ let name_w = rows.iter().map(|(n, _)| ui.painter().layout_no_wrap(n.to_string(), font.clone(), t.text).size().x).fold(0.0_f32, f32::max);
+ egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, false]).show(ui, |ui| {
+ if rows.is_empty() {
+ ui.label(egui::RichText::new(language.tr("No functions match.")).italics());
+ }
+ let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
+ for (i, (name, category)) in rows.iter().enumerate() {
+ let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width().max(name_w + 120.0), row_h), egui::Sense::click());
+ if i == d.list_index {
+ ui.painter().rect_filled(rect, 3.0, ui.visuals().selection.bg_fill);
+ } else if r.hovered() {
+ ui.painter().rect_filled(rect, 3.0, t.hover);
+ }
+ let color = if i == d.list_index { ui.visuals().selection.stroke.color } else { t.text };
+ ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, *name, font.clone(), color);
+ ui.painter().text(rect.left_center() + vec2(6.0 + name_w + 16.0, 0.0), egui::Align2::LEFT_CENTER, *category, font.clone(), t.text_dim);
+ r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == d.list_index, format!("{name} ({category})")));
if r.clicked() {
d.list_index = i;
}
@@ -38,15 +102,19 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia
}
});
if let Some(f) = hits.get(d.list_index) {
- let name = f["name"].as_str().unwrap_or("");
ui.separator();
- ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong());
- ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default());
+ ui.label(egui::RichText::new(crate::formula_locale::signature(&f.name, locale, language).unwrap_or_default()).strong());
+ ui.label(crate::formula_locale::description(&f.name, locale, language).unwrap_or_default());
}
let mut open = true;
ok_cancel(ui, confirm, &mut open);
- if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
- let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n);
+ // The function counts as recently used once the formula is entered (the engine notes it).
+ if *confirm && let Some(f) = hits.get(d.list_index) {
+ let n = app
+ .session
+ .active()
+ .map(|doc| crate::formula_locale::completion_name(&f.name, locale, &doc.wb, doc.wb.active_sheet()))
+ .unwrap_or(&f.name);
let cur = app.editor.as_ref().map(|e| e.text.clone());
let text = match cur {
Some(t) if t.starts_with('=') => format!("{t}{n}("),
@@ -58,15 +126,3 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia
*confirm = true;
}
}
-
-/// Excel's name for a function category, given the catalog's id (`MathTrig`).
-fn function_category_label(id: &str) -> &str {
- match id {
- "DateTime" => "Date & Time",
- "Lookup" => "Lookup & Reference",
- "MathTrig" => "Math & Trig",
- "AddIn" => "Add-in and Automation",
- // Financial, Logical, Text, Statistical, Engineering, Database, Information, …
- other => other,
- }
-}
diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs
index 071a94e5b..a2e452fb0 100644
--- a/crates/ui-egui/src/dialogs/show.rs
+++ b/crates/ui-egui/src/dialogs/show.rs
@@ -464,7 +464,7 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
/// Enter sets the width, as in Excel.
/// Keeps asking until the field really has focus: a new window's first frame is an invisible
/// sizing pass where a focus request is ignored.
-fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
+pub(super) fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
if !d.focus_first_field {
return;
}
diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs
index 7ee1efaa1..7bf3a799d 100644
--- a/crates/ui-egui/src/doc_windows.rs
+++ b/crates/ui-egui/src/doc_windows.rs
@@ -160,6 +160,15 @@ impl DocWindows {
self.list.focus(id);
self.viewports.focus(ctx, id);
}
+ // macOS ignores Focus for a minimized window or a hidden app, so restore and show first.
+ if std::mem::take(&mut app.bring_forward)
+ && let Some(id) = self.list.active()
+ {
+ let vp = self.viewports.viewport_id(id);
+ ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Visible(true));
+ ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Minimized(false));
+ ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Focus);
+ }
}
/// Draws every window. Call from the root viewport's UI each frame, in place of
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..cbc043ece 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" => "Σύστημα",
@@ -580,7 +577,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Επεξεργασία ονόματος",
"New Name" => "Νέο όνομα",
"Format Cells" => "Μορφοποίηση κελιών",
- "Formula Builder" => "Δημιουργός τύπων",
"Find and Replace" => "Εύρεση και αντικατάσταση",
"Go To" => "Μετάβαση",
"Insert Calculated Field" => "Εισαγωγή υπολογιζόμενου πεδίου",
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..599b9c2a5 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",
@@ -456,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Modifica nome",
"New Name" => "Nuovo nome",
"Format Cells" => "Formato celle",
- "Formula Builder" => "Generatore di formule",
"Find and Replace" => "Trova e sostituisci",
"Go To" => "Vai a",
"Insert Calculated Field" => "Inserisci campo calcolato",
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
index 1f06755dd..65015dfe0 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", "印刷する内容"),
@@ -477,7 +475,6 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
("Edit Name", "名前の編集"),
("New Name", "新しい名前"),
("Format Cells", "セルの書式設定"),
- ("Formula Builder", "数式ビルダー"),
("Find and Replace", "検索と置換"),
("Go To", "ジャンプ"),
("Insert Calculated Field", "計算のフィールドの挿入"),
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..ae4639760 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",
@@ -450,7 +447,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Edytowanie nazwy",
"New Name" => "Nowa nazwa",
"Format Cells" => "Formatowanie komórek",
- "Formula Builder" => "Konstruktor formuł",
"Find and Replace" => "Znajdowanie i zamienianie",
"Go To" => "Przechodzenie do",
"Insert Calculated Field" => "Wstawianie pola obliczeniowego",
diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs
index c79be64e1..32b38a953 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",
@@ -471,7 +469,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Editar Nome",
"New Name" => "Novo Nome",
"Format Cells" => "Formatar Células",
- "Formula Builder" => "Construtor de Fórmulas",
"Find and Replace" => "Localizar e Substituir",
"Go To" => "Ir para",
"Insert Calculated Field" => "Inserir Campo Calculado",
diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs
index 058911d87..f92555df2 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",
@@ -456,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Editare nume",
"New Name" => "Nume nou",
"Format Cells" => "Formatare celule",
- "Formula Builder" => "Generator de formule",
"Find and Replace" => "Găsire și înlocuire",
"Go To" => "Salt la",
"Insert Calculated Field" => "Inserare câmp calculat",
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..699a22288 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" => "Анімація",
@@ -454,7 +451,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Edit Name",
"New Name" => "New Name",
"Format Cells" => "Format Cells",
- "Formula Builder" => "Formula Builder",
"Find and Replace" => "Find and Replace",
"Go To" => "Go To",
"Insert Calculated Field" => "Insert Calculated Field",
@@ -711,9 +707,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Hidden" => "Hidden",
"Search:" => "Search:",
"e.g. lookup, average, date" => "e.g. lookup, average, date",
- "Date & Time" => "Date & Time",
- "Lookup & Reference" => "Lookup & Reference",
- "Math & Trig" => "Math & Trig",
+ "Date & Time" => "Дата і час",
+ "Lookup & Reference" => "Пошук і посилання",
+ "Math & Trig" => "Математичні і тригонометричні",
"Statistical" => "Statistical",
"Engineering" => "Engineering",
"Database" => "Database",
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.rs b/crates/ui-egui/src/keytips/keytip_table.rs
index d14af141b..87c85befd 100644
--- a/crates/ui-egui/src/keytips/keytip_table.rs
+++ b/crates/ui-egui/src/keytips/keytip_table.rs
@@ -92,6 +92,30 @@ pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> {
sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
}
+/// True when `prefix` is the group level of a menu control: some of `target`'s sequences finish
+/// one key below it (Borders' "HB" before HBA..HBW). The menu opens there, as Excel's does, so its
+/// item letters show.
+pub fn opens_menu(prefix: &str, target: &str) -> bool {
+ !prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
+}
+
+/// The badge for a menu item that runs `target` with `params`: its finish sequence one key below
+/// the prefix. Items sharing one command (the Borders presets) are told apart by their params,
+/// compared as JSON values, so key order and spacing don't matter.
+pub fn item_letter_with(prefix: &str, target: &str, params: &serde_json::Value) -> Option<&'static str> {
+ SEQUENCES.iter().find_map(|(seq, entry)| match entry {
+ Entry::Finish(Action::CommandWith(id, p))
+ if *id == target
+ && seq.len() == prefix.len() + 1
+ && seq.starts_with(prefix)
+ && serde_json::from_str::(p).is_ok_and(|p| p == *params) =>
+ {
+ Some(*seq)
+ }
+ _ => None,
+ })
+}
+
/// Badge sequences for a command control: every sequence for `target` that continues from the
/// current prefix (the painter strips the prefix, so the remaining letters show).
pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> {
@@ -197,6 +221,50 @@ mod tests {
assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item");
}
+ #[test]
+ fn menu_levels_and_item_letters_with_params() {
+ use serde_json::json;
+ assert!(opens_menu("HB", "home.borders"));
+ // Prefixes that are not the Borders menu level don't open it.
+ assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu");
+ assert!(!opens_menu("", "home.borders"));
+ assert!(!opens_menu("HBA", "home.borders"), "a finished sequence is not a menu level");
+ assert!(!opens_menu("HI", "home.borders"), "another group's level");
+ assert!(!opens_menu("HB", "home.fillColor"), "the Borders level is not another control's menu");
+ // Presets sharing home.borders are told apart by their params.
+ assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "all"})), Some("HBA"));
+ assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "bottom"})), Some("HBO"));
+ assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "nope"})), None);
+ assert_eq!(item_letter_with("H", "home.borders", &json!({"preset": "all"})), None);
+ // Params compare as JSON values: key order and spacing don't matter.
+ let parsed: serde_json::Value = serde_json::from_str("{ \"preset\" : \"all\" }").unwrap();
+ assert_eq!(item_letter_with("HB", "home.borders", &parsed), Some("HBA"));
+ assert_eq!(
+ json!({"b": 1, "a": 2}),
+ serde_json::from_str::("{\"a\":2,\"b\":1}").unwrap(),
+ "maps compare regardless of order"
+ );
+ // Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J).
+ for preset in [
+ "bottom",
+ "top",
+ "left",
+ "right",
+ "none",
+ "all",
+ "outside",
+ "thickOutside",
+ "doubleBottom",
+ "thickBottom",
+ "topBottom",
+ "topThickBottom",
+ "topDoubleBottom",
+ "inside",
+ ] {
+ assert!(item_letter_with("HB", "home.borders", &json!({"preset": preset})).is_some(), "{preset} has no letter");
+ }
+ }
+
#[test]
fn item_letter_is_one_level_deep() {
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs
index 2a2509f08..6774474fd 100644
--- a/crates/ui-egui/src/keytips/keytip_table/generated.rs
+++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs
@@ -46,12 +46,13 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item
("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu
- ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
- ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
- ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
+ ("HBJ", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"inside\"}"))), // Excel: (none); Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB
+ ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
+ ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
+ ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item
- ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
- ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
+ ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
+ ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item
("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item
("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item
@@ -242,6 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference)
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
+ ("MR", Entry::Finish(Action::Control("formulas.recentlyUsed"))), // Excel: Recently Used Menu; opens the Recently Used menu
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json
index 5dfe7b2f5..d90053e2f 100644
--- a/crates/ui-egui/src/keytips/keytips.json
+++ b/crates/ui-egui/src/keytips/keytips.json
@@ -273,6 +273,16 @@
"excel": "Line Color",
"note": "pending: Line Color submenu"
},
+ {
+ "seq": "HBJ",
+ "kind": "command",
+ "target": "home.borders",
+ "params": {
+ "preset": "inside"
+ },
+ "excel": "(none)",
+ "note": "Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB"
+ },
{
"seq": "HBL",
"kind": "command",
@@ -1658,6 +1668,13 @@
"note": "group level: containment(More Functions)",
"target": "formulas.moreFunctions"
},
+ {
+ "seq": "MR",
+ "kind": "control",
+ "excel": "Recently Used Menu",
+ "note": "opens the Recently Used menu",
+ "target": "formulas.recentlyUsed"
+ },
{
"seq": "MS",
"kind": "continue",
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index db9ab316f..2bf01611e 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;
@@ -284,6 +285,9 @@ pub struct SheetApp {
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
pub allow_close: bool,
+ /// A quit question is waiting in the active window: [`doc_windows`] brings its native window
+ /// to the front, also when the app is hidden or the window minimized (a Dock quit).
+ pub bring_forward: bool,
/// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a
/// key tip, the command search or a menu, possibly while another tab was showing).
pub(crate) open_switch_windows: bool,
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 c061dfac3..c46447092 100644
--- a/crates/ui-egui/src/ribbon/home.rs
+++ b/crates/ui-egui/src/ribbon/home.rs
@@ -158,7 +158,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
act(app, "home.borders", json!({"preset": app.grid.last_border.clone()}));
}
ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets"));
- egui::Popup::menu(&ba).show(|ui| {
+ // Alt, H, B opens the menu with each item's letter, as Excel's does (#2964); a letter
+ // or Esc changes the prefix, which closes it again (`ribbon_keys`).
+ let prefix = app.keytips.prefix().unwrap_or_default().to_owned();
+ let keyed = keytip_table::opens_menu(&prefix, "home.borders");
+ egui::Popup::menu(&ba).open_memory(popup_command(keyed, ba.clicked())).show(|ui| {
for (label, preset) in [
("Bottom Border", "bottom"),
("Top Border", "top"),
@@ -175,13 +179,21 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
("Top and Double Bottom Border", "topDoubleBottom"),
("Inside Borders", "inside"),
] {
- if crate::border_preview::preset_button(ui, label, preset).clicked() {
+ let item = crate::border_preview::preset_button(ui, label, preset);
+ if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset})) {
+ keytip(app, ui, sequence, egui::pos2(item.rect.right() - 10.0, item.rect.center().y));
+ }
+ if item.clicked() {
app.grid.last_border = preset.to_string();
act(app, "home.borders", json!({"preset": preset}));
}
}
ui.separator();
- if ui.button("More Borders…").clicked() {
+ let more = ui.button("More Borders…");
+ if let Some(sequence) = keytip_table::item_letter(&prefix, "dialog:formatCells") {
+ keytip(app, ui, sequence, egui::pos2(more.rect.right() - 10.0, more.rect.center().y));
+ }
+ if more.clicked() {
app.open_dialog("formatCells", json!({"tab": "Border"}));
}
});
@@ -468,7 +480,7 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
if s {
act(app, "formulas.autoSum", json!({}));
}
- egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
+ egui::Popup::menu(&sa).show(|ui| crate::function_library::auto_sum_menu(app, ui));
});
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
for sequence in keytip_table::group_extension(
@@ -561,20 +573,6 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
});
}
-/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
-/// Function dialog.
-pub(super) fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
- for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
- if ui.button(f).clicked() {
- act(app, "formulas.autoSum", json!({"function": f}));
- }
- }
- ui.separator();
- if ui.button("More Functions…").clicked() {
- app.open_dialog("insertFunction", json!({}));
- }
-}
-
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let name = st.font.name.clone();
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
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/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs
index bc6f31a3d..dbabe9b28 100644
--- a/crates/ui-egui/src/window_ui.rs
+++ b/crates/ui-egui/src/window_ui.rs
@@ -26,15 +26,19 @@ pub struct WindowUi {
selected_chart: Option,
tab_run: Option<(u64, usize, u32, CellRef)>,
open_switch_windows: bool,
+ /// The ribbon tab this window shows. A contextual tab depends on this window's selection, so
+ /// another window's frame must not reset it.
+ ribbon_tab: String,
/// The status bar's Ready/Enter/Edit, which follows the window's cell draft.
mode: gridcraft_engine::Mode,
}
impl WindowUi {
/// A new window's state. The last border, fill and font colour picked carry over, as they are
- /// the user's recent choices rather than the window's.
- fn fresh(like: &grid::GridState) -> WindowUi {
+ /// the user's recent choices rather than the window's; so does the ribbon tab.
+ fn fresh(like: &grid::GridState, ribbon_tab: &str) -> WindowUi {
WindowUi {
+ ribbon_tab: ribbon_tab.to_string(),
grid: grid::GridState {
last_border: like.last_border.clone(),
last_fill: like.last_fill.clone(),
@@ -64,6 +68,7 @@ impl SheetApp {
selected_chart,
tab_run,
open_switch_windows,
+ ribbon_tab,
mode,
} = w;
std::mem::swap(&mut self.editor, editor);
@@ -79,6 +84,7 @@ impl SheetApp {
std::mem::swap(&mut self.selected_chart, selected_chart);
std::mem::swap(&mut self.tab_run, tab_run);
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
+ std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab);
std::mem::swap(&mut self.session.mode, mode);
}
@@ -100,7 +106,7 @@ impl SheetApp {
self.ui_window = active;
return;
};
- let mut outgoing = WindowUi::fresh(&self.grid);
+ let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab);
self.swap_window_ui(&mut outgoing);
if self.session.window(previous).is_some() {
self.parked.insert(previous, outgoing);
diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs
index 46427f3bf..1d72739e3 100644
--- a/crates/ui-egui/tests/doc_windows.rs
+++ b/crates/ui-egui/tests/doc_windows.rs
@@ -232,6 +232,23 @@ fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() {
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}");
}
+#[test]
+fn a_quit_question_brings_its_window_to_the_front_from_the_background() {
+ // A Dock quit reaches the app while it is behind other apps, hidden or minimized.
+ let mut desk = Desk::new(true);
+ desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
+ desk.frames(3);
+ let w = desk.window_of(0);
+
+ let sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
+ assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
+ for cmd in [ViewportCommand::Visible(true), ViewportCommand::Minimized(false), ViewportCommand::Focus] {
+ assert!(sent_to(&sent, desk.viewport(w), &cmd), "{cmd:?} missing: {sent:?}");
+ }
+ let sent = desk.frames(2);
+ assert!(!sent_to(&sent, desk.viewport(w), &ViewportCommand::Focus), "only once: {sent:?}");
+}
+
#[test]
fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() {
let mut desk = Desk::new(true);
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/contextual_ribbon_tab.rs b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs
index ae318a22a..7ca8e8a21 100644
--- a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs
+++ b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs
@@ -74,3 +74,49 @@ fn an_ordinary_tab_stays_put_in_a_session() {
h.run_steps(3);
assert_eq!(h.state().ui.ribbon_tab, "Data");
}
+
+#[test]
+fn a_second_window_does_not_reset_the_first_windows_table_design_tab() {
+ use egui::{Event, PointerButton, Shape};
+
+ let mut h = harness(session_with_table());
+ let first = h.state().session.active_window().unwrap();
+ // A second window on the same workbook, with its active cell outside the table.
+ h.state_mut().run("view.newWindow", json!({})).unwrap();
+ let second = h.state().session.active_window().unwrap();
+ h.state_mut().run("selection.set", json!({"cell": "D10"})).unwrap();
+ h.state_mut().run("window.activate", json!({"window": first})).unwrap();
+ h.run_steps(2);
+ let tab_center = h
+ .output()
+ .shapes
+ .iter()
+ .find_map(|s| match &s.shape {
+ Shape::Text(t) if t.galley.job.text == "Table Design" => Some(t.pos + t.galley.size() / 2.0),
+ _ => None,
+ })
+ .expect("the Table Design tab is painted while a cell is in the table");
+ for pressed in [true, false] {
+ h.input_mut().events.push(Event::PointerButton {
+ pos: tab_center,
+ button: PointerButton::Primary,
+ pressed,
+ modifiers: egui::Modifiers::NONE,
+ });
+ h.run_steps(1);
+ }
+ h.run_steps(2);
+ assert_eq!(h.state().ui.ribbon_tab, "Table Design");
+ // DocWindows draws every window each frame. The second window's cell is outside the table,
+ // so its frame falls back to Home; the first window's Table Design tab must survive it.
+ h.state_mut().show_window(second);
+ h.run_steps(2);
+ assert_eq!(h.state().ui.ribbon_tab, "Home", "the second window shows Home for its own selection");
+ h.state_mut().enter_window(first);
+ h.run_steps(2);
+ assert_eq!(h.state().session.active_window(), Some(first));
+ assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the first window's tab survives the second window's frame");
+ let d = h.state().session.active().unwrap();
+ assert_eq!(d.selection.active.a1(), "A2", "the first window still selects a cell in the table");
+ assert!(d.wb.active().unwrap().table_at(d.selection.active).is_some(), "the table is still there");
+}
diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs
index fb0779631..7cc455fa7 100644
--- a/crates/ui-egui/tests/it/file_menu.rs
+++ b/crates/ui-egui/tests/it/file_menu.rs
@@ -62,6 +62,74 @@ fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() {
assert!(h.state().ui.ribbon_collapsed);
}
+/// Esc first cancels the focused File-page field, then closes File on the next press.
+#[test]
+fn escape_leaves_file_fields_before_closing_the_view() {
+ use egui::accesskit::Role;
+
+ for (page_id, label) in [
+ ("info", "Title"),
+ ("info", "Subject"),
+ ("info", "Author"),
+ ("info", "Company"),
+ ("info", "Keywords"),
+ ("info", "Description"),
+ ("options", "Maximum Iterations:"),
+ ("options", "Maximum Change:"),
+ ("print", "Copies"),
+ ] {
+ let services = Services {
+ printers: Some(Box::new(|| {
+ let (tx, rx) = std::sync::mpsc::channel();
+ tx.send(Vec::new()).unwrap();
+ rx
+ })),
+ ..Default::default()
+ };
+ let mut session = Session::new();
+ session.new_workbook();
+ let mut h = crate::common::harness_with(
+ crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)),
+ crate::common::prepare(SheetApp::new(session, services)),
+ 4,
+ );
+ h.state_mut().run("formulas.calculationOptions", serde_json::json!({"iterative": true})).unwrap();
+ h.state_mut().run("ui.backstage", serde_json::json!({"page": page_id})).unwrap();
+ h.run_steps(3);
+ let role = if page_id == "print" { Role::SpinButton } else { Role::TextInput };
+ h.get_by_role_and_label(role, label).click();
+ h.run_steps(2);
+ assert!(h.ctx.text_edit_focused(), "{label} has the caret");
+ let original = h.get_by_role_and_label(role, label).value();
+ let revision = h.state().session.active().unwrap().revision;
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
+ h.run_steps(1);
+ h.input_mut().events.push(egui::Event::Text("7".into()));
+ h.run_steps(2);
+ if page_id == "print" {
+ assert_eq!(h.state().printing.settings.copies, 7, "Copies applies the entered value while editing");
+ } else {
+ assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7"));
+ }
+ let copies = h.state().printing.settings.copies;
+
+ h.key_press(egui::Key::Escape);
+ h.run_steps(3);
+ assert_eq!(page(&h), Some(page_id), "Esc in {label} keeps File open");
+ assert!(!h.ctx.text_edit_focused(), "Esc leaves {label}");
+ assert_eq!(h.state().session.active().unwrap().revision, revision, "Esc does not commit {label}");
+ if page_id == "print" {
+ assert_eq!(h.state().printing.settings.copies, copies, "leaving Copies preserves its existing live value");
+ } else {
+ assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value(), original, "Esc discards the {label} draft");
+ }
+
+ h.key_press(egui::Key::Escape);
+ h.run_steps(3);
+ assert_eq!(page(&h), None, "the next Esc returns to the sheet");
+ }
+}
+
#[test]
fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() {
let downloads = Arc::new(Mutex::new(Vec::<(String, Vec)>::new()));
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..a1a7a1a37
--- /dev/null
+++ b/crates/ui-egui/tests/it/function_library.rs
@@ -0,0 +1,332 @@
+//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions
+//! cascade, the AutoSum big split button, the Recently Used keytip and list, and the Insert Function
+//! dialog's categories.
+
+use egui::os::OperatingSystem;
+use egui::{Event, Key, Modifiers, vec2};
+use egui_kittest::kittest::Queryable as _;
+use gridcraft_engine::Session;
+use gridcraft_engine::core::CellRef;
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+type Harness = egui_kittest::Harness<'static, SheetApp>;
+
+fn formulas_tab(width: f32) -> Harness {
+ let mut session = Session::new();
+ session.new_workbook();
+ let mut app = crate::common::app(session);
+ app.ui.ribbon_tab = "Formulas".into();
+ crate::common::harness_with(crate::common::builder().with_os(OperatingSystem::Nix).with_size(vec2(width, 760.0)), app, 4)
+}
+
+fn editor_text(h: &Harness) -> Option {
+ h.state().editor.as_ref().map(|e| e.text.clone())
+}
+
+fn input(h: &Harness, cell: &str) -> String {
+ let d = h.state().session.active().unwrap();
+ d.wb.active().unwrap().input_text(CellRef::parse(cell).unwrap())
+}
+
+/// Clicks a button by its name (a tooltip also names an icon-only button, such as the formula
+/// bar's fx, which is not a button to accessibility).
+fn click(h: &mut Harness, label: &str) {
+ h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
+ h.run_steps(3);
+}
+
+/// Clicks a menu row through accessibility, which reaches rows scrolled out of a long menu.
+fn pick(h: &mut Harness, label: &str) {
+ h.get_by_label(label).click_accesskit();
+ h.run_steps(3);
+}
+
+fn close_menus(h: &mut Harness) {
+ h.key_press(Key::Escape);
+ h.run_steps(2);
+ h.state_mut().editor = None;
+ h.run_steps(2);
+}
+
+#[test]
+fn autosum_is_a_big_split_button_then_the_categories_in_columns_of_three() {
+ let h = formulas_tab(1440.0);
+ let columns: [&[&str]; 3] =
+ [&["Recently Used", "Financial", "Logical"], &["Text", "Date & Time", "Lookup & Reference"], &["Math & Trig", "More Functions"]];
+ let rect = |l: &str| h.get_by_role_and_label(egui::accesskit::Role::Button, l).rect();
+ for column in columns {
+ for pair in column.windows(2) {
+ let (a, b) = (rect(pair[0]), rect(pair[1]));
+ assert!((a.left() - b.left()).abs() < 0.5, "{pair:?} share a left edge");
+ assert!(a.bottom() <= b.top() + 0.5, "{pair:?} stack in order");
+ }
+ }
+ for pair in columns.windows(2) {
+ assert!(rect(pair[0][0]).right() <= rect(pair[1][0]).left(), "columns run left to right");
+ }
+ // Insert Function and AutoSum are big buttons, to the left of the columns.
+ let (insert, sum) = (rect("Insert Function"), rect("AutoSum"));
+ assert!(insert.right() <= sum.left() && sum.right() <= rect("Recently Used").left());
+ assert!(sum.top() <= rect("Recently Used").top() + 0.5, "AutoSum starts at the top of the group");
+ assert!(h.get_by_label("AutoSum options").rect().bottom() > rect("Financial").bottom(), "its arrow part reaches below the second row");
+ // A full column stands where the tab's other columns of three small buttons do (Formula
+ // Auditing's), so it fits the ribbon in any font.
+ let (top, bottom) = (rect("Show Formulas").top(), rect("Evaluate Formula").bottom());
+ for column in &columns[..2] {
+ let (t, b) = (rect(column[0]).top(), rect(column[2]).bottom());
+ assert!((t - top).abs() < 0.5 && (b - bottom).abs() < 0.5, "{column:?} spans {t}..{b}, Formula Auditing {top}..{bottom}");
+ }
+}
+
+#[test]
+fn each_category_menu_lists_its_functions_then_insert_function() {
+ let mut h = formulas_tab(1440.0);
+ // Recently Used lists the functions of formulas entered.
+ h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap();
+ h.run_steps(2);
+ for (button, function) in [
+ ("Recently Used", "SUM"),
+ ("Financial", "PMT"),
+ ("Logical", "IF"),
+ ("Text", "CONCAT"),
+ ("Date & Time", "TODAY"),
+ ("Lookup & Reference", "XLOOKUP"),
+ ("Math & Trig", "ROUND"),
+ ] {
+ click(&mut h, button);
+ h.get_by_label("Insert Function…");
+ pick(&mut h, function);
+ assert_eq!(editor_text(&h).as_deref(), Some(format!("={function}(").as_str()), "{button} › {function} starts the formula");
+ close_menus(&mut h);
+ }
+ // The menu's last row opens Insert Function on the menu's category.
+ click(&mut h, "Date & Time");
+ click(&mut h, "Insert Function…");
+ let d = h.state().dialog.as_ref().expect("Insert Function opens");
+ assert_eq!(d.name(), "insertFunction");
+ assert_eq!(d.values.get("category"), Some(&json!("Date & Time")));
+}
+
+#[test]
+fn more_functions_cascades_into_the_remaining_categories() {
+ let mut h = formulas_tab(1440.0);
+ click(&mut h, "More Functions");
+ for c in ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"] {
+ h.get_by_label(c);
+ }
+ click(&mut h, "Engineering");
+ h.get_by_label("Insert Function…");
+ pick(&mut h, "BIN2DEC");
+ assert_eq!(editor_text(&h).as_deref(), Some("=BIN2DEC("));
+ close_menus(&mut h);
+ click(&mut h, "More Functions");
+ click(&mut h, "Statistical");
+ pick(&mut h, "Insert Function…");
+ assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("category")), Some(&json!("Statistical")));
+}
+
+#[test]
+fn autosum_runs_sum_and_its_arrow_opens_the_shared_menu() {
+ let mut h = formulas_tab(1440.0);
+ {
+ let s = &mut h.state_mut().session;
+ s.execute("range.setValues", json!({"range": "A1", "values": [[1, 4], [2, 5], [3, 6]]})).unwrap();
+ s.execute("selection.set", json!({"cell": "A4"})).unwrap();
+ }
+ h.run_steps(2);
+ click(&mut h, "AutoSum");
+ assert_eq!(input(&h, "A4"), "=SUM(A1:A3)");
+ h.state_mut().session.execute("selection.set", json!({"cell": "B4"})).unwrap();
+ click(&mut h, "AutoSum options");
+ for item in ["Sum", "Average", "Max", "Min", "More Functions…"] {
+ h.get_by_label(item);
+ }
+ click(&mut h, "Count Numbers");
+ assert_eq!(input(&h, "B4"), "=COUNT(B1:B3)");
+ click(&mut h, "AutoSum options");
+ click(&mut h, "More Functions…");
+ assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("insertFunction"));
+}
+
+fn key_event(key: Key, pressed: bool) -> Event {
+ Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
+}
+
+#[test]
+fn the_recently_used_keytip_opens_its_menu() {
+ let mut h = formulas_tab(1440.0);
+ // Recently Used lists the functions of formulas entered.
+ h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap();
+ h.run_steps(2);
+ h.input_mut().events.extend([key_event(Key::F10, true), key_event(Key::F10, false)]);
+ h.run_steps(2);
+ for k in [Key::M, Key::R] {
+ h.input_mut().events.extend([key_event(k, true), Event::Text(k.name().to_ascii_lowercase()), key_event(k, false)]);
+ h.run_steps(3);
+ }
+ assert_eq!(h.state().keytips.prefix(), None, "MR finishes the sequence");
+ h.get_by_label("Insert Function…");
+ pick(&mut h, "AVERAGE");
+ assert_eq!(editor_text(&h).as_deref(), Some("=AVERAGE("));
+}
+
+#[test]
+fn recently_used_lists_the_engines_list_newest_first() {
+ let mut h = formulas_tab(1440.0);
+ // The ribbon and the control channel go through app.run; MCP and scripts through the session.
+ // The engine keeps the list (see the engine's recently_used tests); the menu shows it.
+ h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
+ h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap();
+ h.state_mut().session.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap();
+ h.run_steps(2);
+ assert_eq!(&h.state().session.prefs.recent_functions[..4], ["FV", "IRR", "ROUND", "NPV"]);
+ click(&mut h, "Recently Used");
+ let top = |h: &Harness, name: &str| h.get_by_label(name).rect().top();
+ assert!(top(&h, "FV") < top(&h, "IRR") && top(&h, "IRR") < top(&h, "ROUND") && top(&h, "ROUND") < top(&h, "NPV"));
+}
+
+/// Opens the Insert Function category list (the dialog's only combo box).
+fn open_categories(h: &mut Harness) {
+ h.get_by_role(egui::accesskit::Role::ComboBox).click();
+ h.run_steps(3);
+}
+
+/// Picks a category from the open list (the ribbon has buttons with the same names).
+fn choose(h: &mut Harness, category: &str) {
+ h.get_all_by_label(category).last().expect("the category is listed").click();
+ h.run_steps(3);
+}
+
+fn row(name: &str, category: &str) -> String {
+ format!("{name} ({category})")
+}
+
+#[test]
+fn insert_function_has_its_title_and_a_working_category_list() {
+ let mut h = formulas_tab(1440.0);
+ h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
+ h.run_steps(2);
+ click(&mut h, "Insert Function");
+ let d = h.state().dialog.as_ref().expect("Insert Function opens");
+ assert_eq!(d.title, "Insert Function");
+ // Most Recently Used comes first, newest first.
+ let npv = h.get_by_label(&row("NPV", "Financial")).rect();
+ assert!(npv.top() < h.get_by_label(&row("SUM", "Math & Trig")).rect().top());
+ assert!(h.query_by_label(&row("PMT", "Financial")).is_some(), "the default list follows");
+ // Pick a category from the list.
+ open_categories(&mut h);
+ choose(&mut h, "Date & Time");
+ assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some());
+ assert!(h.query_by_label(&row("NPV", "Financial")).is_none());
+ // All lists everything; a category given by its engine id works too (control channel).
+ open_categories(&mut h);
+ choose(&mut h, "All");
+ assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some() && h.query_by_label(&row("NPV", "Financial")).is_some());
+ h.state_mut().dialog.as_mut().unwrap().values.insert("category".into(), json!("MathTrig"));
+ h.run_steps(2);
+ assert!(h.query_by_label(&row("ROUND", "Math & Trig")).is_some() && h.query_by_label(&row("TODAY", "Date & Time")).is_none());
+ // A search looks through every category.
+ h.state_mut().dialog.as_mut().unwrap().search = "lookup".into();
+ h.run_steps(2);
+ assert_eq!(h.state().dialog.as_ref().unwrap().values.get("category"), Some(&json!("All")));
+ assert!(h.query_by_label(&row("XLOOKUP", "Lookup & Reference")).is_some());
+ // OK starts the formula. The function counts as recently used once the formula is entered,
+ // not before (the edit may still be cancelled, or there may be no cell to edit).
+ pick(&mut h, &row("XLOOKUP", "Lookup & Reference"));
+ click(&mut h, " OK ");
+ assert_eq!(editor_text(&h).as_deref(), Some("=XLOOKUP("));
+ assert_ne!(h.state().session.prefs.recent_functions[0], "XLOOKUP");
+}
+
+#[test]
+fn insert_function_lets_tab_reach_the_category_list() {
+ use egui::accesskit::Role;
+ let mut h = formulas_tab(1440.0);
+ click(&mut h, "Insert Function");
+ h.run_steps(3);
+ let search_focused = |h: &Harness| h.get_all_by_role(Role::TextInput).any(|n| n.is_focused());
+ assert!(search_focused(&h), "the search box takes the focus when the dialog opens");
+ h.input_mut().events.push(Event::Text("npv".into()));
+ h.run_steps(2);
+ assert_eq!(h.state().dialog.as_ref().map(|d| d.search.as_str()), Some("npv"));
+ // Tab moves on to the category list, and the search box does not take the focus back.
+ h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]);
+ h.run_steps(5);
+ assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list");
+ assert!(!search_focused(&h));
+ // The list opens from the keyboard (its Date & Time joins the ribbon's).
+ let listed = |h: &Harness| h.get_all_by_label("Date & Time").count();
+ let before = listed(&h);
+ h.input_mut().events.extend([key_event(Key::Space, true), key_event(Key::Space, false)]);
+ h.run_steps(3);
+ assert_eq!(listed(&h), before + 1, "the list is open");
+}
+
+#[test]
+fn enter_on_the_category_list_does_not_confirm_insert_function() {
+ use egui::accesskit::Role;
+ let mut h = formulas_tab(1440.0);
+ click(&mut h, "Insert Function");
+ h.run_steps(3);
+ let open = |h: &Harness| h.state().dialog.as_ref().is_some_and(|d| d.name == "insertFunction");
+ let listed = |h: &Harness| h.get_all_by_label("Date & Time").count();
+ let closed = listed(&h);
+ h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]);
+ h.run_steps(5);
+ assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list");
+ // Enter on the focused list opens it, as Space does; the dialog stays open and nothing is
+ // inserted.
+ h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
+ h.run_steps(3);
+ assert!(open(&h), "Enter on the category list leaves the dialog open");
+ assert_eq!(editor_text(&h), None, "no function is inserted");
+ assert_eq!(listed(&h), closed + 1, "Enter opens the list");
+ // Enter again, with the list open, closes it and still does not confirm.
+ h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
+ h.run_steps(3);
+ assert!(open(&h), "Enter in the open list leaves the dialog open");
+ assert_eq!(editor_text(&h), None);
+ // Back in the search box (Shift+Tab), Enter confirms as before.
+ let shift_tab = |pressed| Event::Key { key: Key::Tab, physical_key: None, pressed, repeat: false, modifiers: Modifiers::SHIFT };
+ h.input_mut().events.extend([shift_tab(true), shift_tab(false)]);
+ h.run_steps(3);
+ assert!(!h.get_by_role(Role::ComboBox).is_focused(), "Shift+Tab leaves the category list");
+ h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
+ h.run_steps(3);
+ assert!(!open(&h), "Enter in the search box confirms");
+ assert!(editor_text(&h).is_some_and(|t| t.starts_with('=') && t.ends_with('(')), "{:?}", editor_text(&h));
+}
+
+/// The text shapes the last frame painted, with their left edge and width.
+fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> {
+ let mut out = Vec::new();
+ for clipped in &h.output().shapes {
+ if let egui::Shape::Text(t) = &clipped.shape {
+ out.push((t.galley.text().to_string(), t.pos.x, t.galley.size().x));
+ }
+ }
+ out
+}
+
+#[test]
+fn insert_function_keeps_the_category_column_clear_of_long_names() {
+ let mut h = formulas_tab(1440.0);
+ // A wide interface font (as on some Windows and Linux desktops, or a long formula language)
+ // makes every name wider than the dialog was laid out for.
+ h.ctx.all_styles_mut(|s| {
+ s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(20.0));
+ });
+ click(&mut h, "Insert Function");
+ // Some of the longest function names in the catalog, and a short one, in one list (the
+ // signature under the list starts with a name too, and is not a row).
+ h.state_mut().dialog.as_mut().unwrap().search = "forecast".into();
+ h.run_steps(3);
+ let shapes = painted_text(&h);
+ let names: Vec<&(String, f32, f32)> = shapes.iter().filter(|(t, _, _)| t.starts_with("FORECAST") && !t.contains('(')).collect();
+ let column = shapes.iter().filter(|(t, _, _)| t == "Statistical").map(|(_, x, _)| *x).fold(f32::INFINITY, f32::min);
+ assert!(names.iter().any(|(t, _, _)| t == "FORECAST.ETS.SEASONALITY"), "the long name is listed: {shapes:?}");
+ for (name, x, w) in names {
+ assert!(x + w < column, "{name} ends at {} and the category column starts at {column}", x + w);
+ }
+}
diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs
index 810338a48..c563d0d13 100644
--- a/crates/ui-egui/tests/it/keytips_generated.rs
+++ b/crates/ui-egui/tests/it/keytips_generated.rs
@@ -338,6 +338,13 @@ fn keytip_hbi_() {
assert!(h.state().session.journal.is_empty(), "HBI must not journal");
}
+#[test]
+fn keytip_hbj_() {
+ let mut h = harness(blank());
+ sequence(&mut h, "HBJ", false);
+ assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBJ must journal home.borders");
+}
+
#[test]
fn keytip_hbl_() {
let mut h = harness(blank());
@@ -1764,6 +1771,14 @@ fn keytip_mq_() {
assert_eq!(h.state().keytips.prefix(), None);
}
+#[test]
+fn keytip_mr_() {
+ let mut h = harness(blank());
+ sequence(&mut h, "MR", false);
+ assert!(h.state().message.is_none() && h.state().toast.is_none(), "MR must route to its control without an error box");
+ assert_eq!(h.state().keytips.prefix(), None);
+}
+
#[test]
fn keytip_ms_() {
let mut h = harness(blank());
@@ -2754,7 +2769,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
- const EXPECTED: usize = 365;
+ const EXPECTED: usize = 367;
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 a75e651e6..a70dc449b 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;
@@ -80,6 +81,7 @@ mod pivot_show_details;
mod pivot_show_details_menu;
mod popup_dismissal;
mod protect_password;
+mod protected_cell;
mod python_cell_edit;
mod ribbon_layout;
mod right_to_left;
diff --git a/crates/ui-egui/tests/it/protected_cell.rs b/crates/ui-egui/tests/it/protected_cell.rs
new file mode 100644
index 000000000..15a3f5fd7
--- /dev/null
+++ b/crates/ui-egui/tests/it/protected_cell.rs
@@ -0,0 +1,70 @@
+//! Typing into a locked cell of a protected sheet warns before an editor opens, and the warning
+//! leaves Undo, Save and typing in unlocked cells working (#2945).
+
+use egui::{Event, Key, Modifiers};
+use gridcraft_engine::Session;
+use gridcraft_engine::core::{CellRef, Value};
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+type Harness = egui_kittest::Harness<'static, SheetApp>;
+
+const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
+
+/// A protected sheet whose C2 is unlocked and holds "keep"; A1 is locked and active.
+fn harness() -> Harness {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap();
+ s.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap();
+ s.execute("review.protectSheet", json!({})).unwrap();
+ s.execute("selection.set", json!({"cell": "A1"})).unwrap();
+ crate::support::harness(s, 4)
+}
+
+fn key(h: &mut Harness, k: Key) {
+ h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE });
+ h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE });
+ h.run_steps(2);
+}
+
+/// Types text the way a keyboard does (into whatever has focus).
+fn type_text(h: &mut Harness, text: &str) {
+ h.input_mut().events.push(Event::Text(text.into()));
+ h.run_steps(2);
+}
+
+fn value(h: &Harness, a1: &str) -> Value {
+ let at = CellRef::parse(a1).unwrap();
+ h.state().session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default()
+}
+
+#[test]
+fn typing_in_a_locked_cell_warns_without_a_stuck_draft() {
+ let mut h = harness();
+ let undo = h.state().session.active().unwrap().undo.len();
+
+ type_text(&mut h, "locked");
+
+ assert!(h.state().editor.is_none(), "no editor opened on the locked cell");
+ assert_eq!(h.state().message.as_ref().map(|(_, m)| m.as_str()), Some(PROTECTED));
+ assert_eq!(h.state().session.active().unwrap().undo.len(), undo, "the refused edit added no undo step");
+ assert_eq!(value(&h, "A1"), Value::Empty);
+ // No draft exists, so Save is not blocked while the warning is up.
+ assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
+
+ // Enter dismisses the warning and the keyboard goes back to the grid.
+ key(&mut h, Key::Enter);
+ assert!(h.state().message.is_none(), "Enter dismisses the warning");
+
+ // An unlocked cell on the same sheet still edits, undoes and saves.
+ h.state_mut().run("selection.set", json!({"cell": "C2"})).unwrap();
+ h.run_steps(1);
+ type_text(&mut h, "typed");
+ assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("typed"));
+ key(&mut h, Key::Enter);
+ assert_eq!(value(&h, "C2"), Value::text("typed"));
+ assert!(h.state_mut().run("edit.undo", json!({})).is_ok());
+ assert_eq!(value(&h, "C2"), Value::text("keep"), "Undo of the unlocked cell's edit still works");
+ assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
+}
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/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs
index b4418018a..e3fc7a9dc 100644
--- a/crates/ui-egui/tests/keytips.rs
+++ b/crates/ui-egui/tests/keytips.rs
@@ -768,3 +768,38 @@ fn border_keytips_without_a_command_stay_silent() {
assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border");
}
}
+
+/// #2964: Alt, H, B opens the Borders menu (Excel shows it with each item's letter) instead of
+/// leaving nothing on screen; a letter then picks its item and the menu closes, and Esc backs out.
+#[test]
+fn borders_keytip_opens_the_borders_menu() {
+ let mut h = harness(selected("A1:B2"));
+ sequence(&mut h, &[Key::H, Key::B], false);
+ h.run_steps(2);
+ assert_eq!(h.state().keytips.prefix(), Some("HB"));
+ assert!(egui::Popup::is_any_open(&h.ctx), "Alt, H, B opens the Borders menu");
+ assert!(h.query_by_label("All Borders").is_some(), "the menu's items show");
+ letter(&mut h, Key::A, false);
+ h.run_steps(2);
+ assert!(!egui::Popup::is_any_open(&h.ctx), "picking an item closes the menu");
+ assert_eq!(h.state().keytips.prefix(), None);
+ assert!(sides(&h, "B2").iter().all(|s| *s != gridcraft_engine::model::BorderStyle::None), "A applied All Borders");
+
+ // Inside Borders, GridCraft's own item, has a letter too (J): only the inner edges.
+ let mut h = harness(selected("A1:B2"));
+ sequence(&mut h, &[Key::H, Key::B, Key::J], false);
+ h.run_steps(2);
+ let (a1, b2) = (sides(&h, "A1"), sides(&h, "B2"));
+ use gridcraft_engine::model::BorderStyle::{None as N, Thin};
+ assert_eq!(a1, [N, Thin, N, Thin], "A1: right and bottom only");
+ assert_eq!(b2, [Thin, N, Thin, N], "B2: left and top only");
+
+ let mut h = harness(blank());
+ sequence(&mut h, &[Key::H, Key::B], false);
+ h.run_steps(2);
+ assert!(egui::Popup::is_any_open(&h.ctx));
+ key(&mut h, Key::Escape);
+ h.run_steps(2);
+ assert_eq!(h.state().keytips.prefix(), Some("H"), "Esc backs out to the Home letters");
+ assert!(!egui::Popup::is_any_open(&h.ctx), "and closes the menu");
+}
diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs
index 65ef1d17b..a2e5493f1 100644
--- a/crates/xlsx/src/csv.rs
+++ b/crates/xlsx/src/csv.rs
@@ -2,11 +2,12 @@
use std::sync::Arc;
-use gridcraft_core::parse::parse_input;
-use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value};
+use gridcraft_core::date::{days_in_month_in, serial_from_ymd};
+use gridcraft_core::parse::{Parsed, parse_input};
+use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, Value, col_to_letters};
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
-use crate::{CsvOptions, IoError};
+use crate::{CsvOptions, IoError, ReadReport};
/// The largest CSV or tab-separated file written (bytes).
pub const MAX_CSV_BYTES: usize = 512 << 20;
@@ -248,13 +249,246 @@ pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record:
}
}
+/// How many rows and bytes of a text file are read to decide each column's date order.
+const DATE_SAMPLE_ROWS: usize = 200;
+const DATE_SAMPLE_BYTES: usize = 1 << 20;
+
+/// What a column's numeric dates say about the order of day and month.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum DateRule {
+ /// No `d/m/y` or `d-m-y` value in the sample: read as typed input does.
+ NoDates,
+ /// A value reads only month first (`12/31/2026`), none reads only day first.
+ MonthFirst,
+ /// A value reads only day first (`31/05/2026`), none reads only month first.
+ DayFirst,
+ /// Every date is ambiguous (`03/04/2026`): keep the input parser's month-first reading.
+ Ambiguous,
+ /// Values read only day first and values read only month first: the dates stay text.
+ Conflict,
+}
+
+impl DateRule {
+ fn of(day_first: usize, month_first: usize, ambiguous: usize) -> Self {
+ match (day_first > 0, month_first > 0, ambiguous > 0) {
+ (true, true, _) => Self::Conflict,
+ (true, false, _) => Self::DayFirst,
+ (false, true, _) => Self::MonthFirst,
+ (false, false, true) => Self::Ambiguous,
+ (false, false, false) => Self::NoDates,
+ }
+ }
+}
+
+/// The evidence a column's sample gives for its date order.
+#[derive(Clone, Default)]
+struct DateEvidence {
+ day_first: usize,
+ month_first: usize,
+ ambiguous: usize,
+ day_first_example: Option,
+ month_first_example: Option,
+ ambiguous_example: Option,
+}
+
+/// A numeric `d/m/y` or `d-m-y` field, with the serial it has read each way and its time of day.
+struct NumericDate {
+ day_first: Option,
+ month_first: Option,
+ time: Option,
+}
+
+impl NumericDate {
+ /// The field under the column's rule: a date when that reading is valid, else text.
+ fn read(&self, field: &str, rule: DateRule) -> Parsed {
+ let serial = match rule {
+ DateRule::DayFirst => self.day_first,
+ DateRule::MonthFirst | DateRule::Ambiguous => self.month_first,
+ DateRule::Conflict | DateRule::NoDates => None,
+ };
+ match serial {
+ Some(serial) => Parsed {
+ value: Value::number(serial + self.time.unwrap_or(0.0)),
+ format: Some(if self.time.is_some() { "m/d/yyyy h:mm" } else { "m/d/yyyy" }),
+ },
+ None => Parsed { value: Value::text(field), format: None },
+ }
+ }
+}
+
+/// A numeric date written with `/` or `-`, with the serial it has read day first and month first.
+/// `None` when the field isn't such a date (including ISO `yyyy-mm-dd`, month names and dots,
+/// which read one way and keep the input parser).
+fn numeric_date(field: &str, sys: DateSystem) -> Option {
+ let text = field.trim();
+ let (date, time) = split_time(text, sys);
+ // Typed input reads a trailing comma after a date (`3/4/2026,`); do the same here.
+ let date = date.trim_end_matches(',');
+ let mut parts = date.split(['/', '-']);
+ let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?);
+ if parts.next().is_some() {
+ return None;
+ }
+ let (a, b, c) = (a.trim(), b.trim(), c.trim());
+ // ISO dates are unambiguous; the input parser reads them. A three-digit year is not a
+ // date to typed input either.
+ if a.len() == 4 || c.len() == 3 {
+ return None;
+ }
+ let a = parse_digits(a)?;
+ let b = parse_digits(b)?;
+ let year = expand_year(c, parse_digits(c)?);
+ let day_first = valid_date(sys, year, b, a);
+ let month_first = valid_date(sys, year, a, b);
+ if day_first.is_none() && month_first.is_none() {
+ return None;
+ }
+ Some(NumericDate { day_first, month_first, time })
+}
+
+/// Splits a field into its date and a time of day, when the tail reads as a time. Both the first
+/// and last space are tried, so `03/04/2026 10:30 AM` and `03 / 04 / 2026 10:30` both split.
+fn split_time(text: &str, sys: DateSystem) -> (&str, Option) {
+ for at in [text.find(' '), text.rfind(' ')].into_iter().flatten() {
+ let (date, time) = text.split_at(at);
+ let time = time.trim();
+ // Times are short; a long tail is not one, and parsing it would be wasted work.
+ if time.len() > 40 {
+ continue;
+ }
+ if let Some(fraction) = time_of_day(time, sys) {
+ return (date.trim(), Some(fraction));
+ }
+ }
+ (text, None)
+}
+
+fn time_of_day(text: &str, sys: DateSystem) -> Option {
+ match parse_input(text, sys) {
+ Parsed { value: Value::Number(fraction), format: Some(code) } if code.contains('h') => Some(fraction),
+ _ => None,
+ }
+}
+
+fn parse_digits(s: &str) -> Option {
+ if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
+ return None;
+ }
+ s.parse().ok()
+}
+
+/// Expands a one- or two-digit year the way typed input does (`26` is 2026, `30` is 1930).
+fn expand_year(text: &str, year: i64) -> i64 {
+ if text.len() <= 2 { if year < 30 { 2000 + year } else { 1900 + year } } else { year }
+}
+
+/// The serial for a date the input parser accepts, or `None`.
+fn valid_date(sys: DateSystem, year: i64, month: i64, day: i64) -> Option {
+ if !(1..=12).contains(&month) || day < 1 || year < 1900 {
+ return None;
+ }
+ let year = i32::try_from(year).ok()?;
+ let month = u32::try_from(month).ok()?;
+ if day > i64::from(days_in_month_in(sys, year, month)) {
+ return None;
+ }
+ serial_from_ymd(sys, i64::from(year), i64::from(month), day)
+}
+
+/// The first records of the file, bounded by rows and bytes, for date-order inference.
+fn sample_records(text: &str, delim: char, quote: char) -> Vec> {
+ let mut cut = text.len().min(DATE_SAMPLE_BYTES);
+ while !text.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ let truncated = cut < text.len();
+ let sample = text.get(..cut).unwrap_or_default();
+ let mut rows = Vec::new();
+ parse_records(sample, delim, quote, |record| {
+ rows.push(record);
+ rows.len() < DATE_SAMPLE_ROWS
+ });
+ // A cut through the last record leaves a partial field; it says nothing reliable.
+ if truncated {
+ rows.pop();
+ }
+ rows
+}
+
+/// The date rule for each column, from the sample.
+fn infer_date_rules(rows: &[Vec], sys: DateSystem) -> Vec<(DateRule, DateEvidence)> {
+ let width = rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_COLS as usize);
+ let mut columns = vec![DateEvidence::default(); width];
+ for row in rows {
+ for (col, field) in row.iter().enumerate() {
+ let Some(column) = columns.get_mut(col) else { continue };
+ let Some(date) = numeric_date(field, sys) else { continue };
+ let example = || field.trim().chars().take(40).collect::();
+ match (date.day_first.is_some(), date.month_first.is_some()) {
+ (true, true) => {
+ column.ambiguous += 1;
+ column.ambiguous_example.get_or_insert_with(example);
+ }
+ (true, false) => {
+ column.day_first += 1;
+ column.day_first_example.get_or_insert_with(example);
+ }
+ (false, true) => {
+ column.month_first += 1;
+ column.month_first_example.get_or_insert_with(example);
+ }
+ (false, false) => {}
+ }
+ }
+ }
+ columns.into_iter().map(|c| (DateRule::of(c.day_first, c.month_first, c.ambiguous), c)).collect()
+}
+
+/// Warns when the sample could not choose a date order: an all-ambiguous column keeps the
+/// month-first reading, and a column with both kinds keeps its dates as text.
+fn date_warnings(columns: &[(DateRule, DateEvidence)], report: &mut ReadReport) {
+ for (col, (rule, evidence)) in columns.iter().enumerate() {
+ match rule {
+ DateRule::Ambiguous => report.warn(format!(
+ "column {}: every date is ambiguous, so it was read month first ({})",
+ col_to_letters(col as u32),
+ evidence.ambiguous_example.as_deref().unwrap_or("a date")
+ )),
+ DateRule::Conflict => report.warn(format!(
+ "column {}: it holds both day-first and month-first dates ({} and {}), so its dates stay text",
+ col_to_letters(col as u32),
+ evidence.day_first_example.as_deref().unwrap_or("a day-first date"),
+ evidence.month_first_example.as_deref().unwrap_or("a month-first date")
+ )),
+ DateRule::NoDates | DateRule::MonthFirst | DateRule::DayFirst => {}
+ }
+ }
+}
+
+/// Reads one field with the column's date rule; other values go through typed input.
+fn read_field(field: &str, sys: DateSystem, rule: DateRule) -> Parsed {
+ match numeric_date(field, sys) {
+ Some(date) if rule != DateRule::NoDates => date.read(field, rule),
+ _ => parse_input(field, sys),
+ }
+}
+
pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result {
+ read_csv_with_report(bytes, opts).map(|(wb, _)| wb)
+}
+
+/// [`read_csv`], with the warnings an open shows: a column whose date order had to be guessed,
+/// or whose dates stayed text because it holds both orders.
+pub fn read_csv_with_report(bytes: &[u8], opts: &CsvOptions) -> Result<(Workbook, ReadReport), IoError> {
let text = decode_text(bytes);
let quote = if opts.quote == 0 { '"' } else { opts.quote as char };
let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char;
if delim == quote {
return Err(IoError::Format("the delimiter and quote character must differ".into()));
}
+ let date_columns = if opts.parse_values { infer_date_rules(&sample_records(&text, delim, quote), opts.date_system) } else { vec![] };
+ let mut report = ReadReport::default();
+ date_warnings(&date_columns, &mut report);
let mut wb = Workbook::new();
wb.date_system = opts.date_system;
wb.csv_delimiter = u8::try_from(delim).ok();
@@ -276,7 +510,8 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result {
}
let pos = CellRef::new(row, col as u32);
let cell = if opts.parse_values {
- let p = parse_input(&f, opts.date_system);
+ let rule = date_columns.get(col).map_or(DateRule::NoDates, |(rule, _)| *rule);
+ let p = read_field(&f, opts.date_system, rule);
let style = match p.format {
Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) {
Some((_, id)) => *id,
@@ -299,7 +534,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result {
});
wb.styles = styles;
wb.sheets = vec![Arc::new(sheet)];
- Ok(wb)
+ Ok((wb, report))
}
/// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows
diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs
index 966088d5b..29dd20870 100644
--- a/crates/xlsx/src/fmla.rs
+++ b/crates/xlsx/src/fmla.rs
@@ -140,15 +140,119 @@ fn strip_param_prefix(n: String) -> String {
pub fn from_file(text: &str) -> String {
let body = text.strip_prefix('=').unwrap_or(text);
match gridcraft_formula::parse(body) {
- Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x {
- Expr::Name(n) => Expr::Name(strip_param_prefix(n)),
- Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args),
- other => other,
- })),
+ Ok(e) => gridcraft_formula::print(&strip_param_prefixes(e)),
Err(_) => body.to_string(),
}
}
+fn strip_param_prefixes(e: Expr) -> Expr {
+ e.map(&mut |x| match x {
+ Expr::Name(n) => Expr::Name(strip_param_prefix(n)),
+ Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args),
+ other => other,
+ })
+}
+
+/// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of
+/// their functions.
+const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION";
+
+/// Whether `text` may hold Google Sheets' export wrapper: a cheap test before parsing.
+fn mentions_dummy_function(text: &str) -> bool {
+ const NEEDLE: &[u8] = b"DUMMYFUNCTION";
+ text.as_bytes().windows(NEEDLE.len()).any(|w| w.eq_ignore_ascii_case(NEEDLE))
+}
+
+/// The model text of `text` (a formula as read, see [`from_file`]) with its Google Sheets export
+/// wrappers replaced (see [`crate::read_xlsx_with`]), or `None` when nothing changes.
+pub(crate) fn unwrap_google_sheets(text: &str, known: &dyn Fn(&str) -> bool) -> Option {
+ if !mentions_dummy_function(text) {
+ return None;
+ }
+ let e = gridcraft_formula::parse(text.strip_prefix('=').unwrap_or(text)).ok()?;
+ let unwrapped = unwrap_dummy_functions(e, known)?;
+ Some(gridcraft_formula::print(&strip_param_prefixes(unwrapped)))
+}
+
+/// The original formula inside Google Sheets' export wrapper
+/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`, if `e` is that
+/// wrapper, the text parses and every function it calls is `known`. The parser has already undone
+/// the doubled quotes. The cached value is also the cell's ``, which the reader keeps.
+fn dummy_function_inner(e: &Expr, known: &dyn Fn(&str) -> bool) -> Option {
+ let Expr::Call(name, args) = e else { return None };
+ // The parser has already dropped an `_xlfn.` prefix from IFERROR.
+ if !name.eq_ignore_ascii_case("IFERROR") {
+ return None;
+ }
+ let [Expr::Call(dummy, inner), _fallback] = args.as_slice() else { return None };
+ if !dummy.eq_ignore_ascii_case(DUMMY_FUNCTION) {
+ return None;
+ }
+ let [Expr::Text(src)] = inner.as_slice() else { return None };
+ // The text came out of a formula within the length limit, so it is within it too; check
+ // anyway rather than rely on that.
+ if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN {
+ return None;
+ }
+ let parsed = gridcraft_formula::parse(src).ok()?;
+ // A function GridCraft can't calculate would show `#NAME?` where Excel shows the cached
+ // value; a wrapper inside the text is one too, so only one level is ever unwrapped.
+ let mut all_known = true;
+ parsed.walk(&mut |x| {
+ if let Expr::Call(n, _) = x {
+ all_known &= known(n);
+ }
+ });
+ all_known.then_some(parsed)
+}
+
+/// `e` with each Google Sheets export wrapper (see [`dummy_function_inner`]) replaced by the
+/// formula it holds, for the whole formula or anywhere inside it, or `None` when none is. If the
+/// result no longer parses (nesting beyond the parser's limit), nothing is replaced.
+fn unwrap_dummy_functions(e: Expr, known: &dyn Fn(&str) -> bool) -> Option {
+ // The whole formula: no parentheses needed.
+ if let Some(inner) = dummy_function_inner(&e, known) {
+ return Some(inner);
+ }
+ let mut found = false;
+ let rewritten = e.map(&mut |x| match dummy_function_inner(&x, known) {
+ // The printer writes no parentheses of its own, so keep the inner formula's precedence
+ // with explicit ones unless it is a single operand.
+ Some(
+ inner @ (Expr::Call(..)
+ | Expr::Ref(_)
+ | Expr::Name(_)
+ | Expr::Number(_)
+ | Expr::Text(_)
+ | Expr::Bool(_)
+ | Expr::Error(_)
+ | Expr::Paren(_)
+ | Expr::Array(_)
+ | Expr::Struct(_)),
+ ) => {
+ found = true;
+ inner
+ }
+ Some(inner) => {
+ found = true;
+ Expr::Paren(Box::new(inner))
+ }
+ None => x,
+ });
+ if !found {
+ return None;
+ }
+ gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)).ok()?;
+ Some(rewritten)
+}
+
+/// What the engine passes as `known` (`gridcraft_calc::is_known_function`), for tests here: the
+/// function library plus the evaluator's own functions the tests use.
+#[cfg(test)]
+pub(crate) fn test_known_function(n: &str) -> bool {
+ gridcraft_functions::lookup(n).is_some() || ["IF", "IFERROR", "ROW", "INDEX", "LET", "LAMBDA"].iter().any(|s| s.eq_ignore_ascii_case(n))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -284,6 +388,76 @@ mod tests {
assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")");
}
+ /// A formula as the reader leaves it, then with Google Sheets' wrappers unwrapped against
+ /// the real function library.
+ fn read(file: &str) -> String {
+ let text = from_file(file);
+ unwrap_google_sheets(&text, &test_known_function).unwrap_or(text)
+ }
+
+ /// #2871: Google Sheets' export wraps formulas as
+ /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`.
+ #[test]
+ fn google_sheets_dummy_function_wrapper() {
+ // The issue's formula, with its embedded newlines and doubled quotes.
+ let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)";
+ let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))";
+ assert_eq!(read(file), want);
+ assert_eq!(read(&format!("={file}")), want);
+ // The reader itself leaves the wrapper: unwrapping is a separate pass.
+ assert!(from_file(file).contains("DUMMYFUNCTION"));
+ // Any case, and IFERROR with its `_xlfn.` prefix; doubled quotes inside.
+ assert_eq!(read("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\"");
+ assert_eq!(read("iferror(__xludf.DUMMYFUNCTION(\"SORT(A1:A3*2)\"),2)"), "SORT(A1:A3*2)");
+ // Nested: an operator expression keeps its precedence; a call or operand needs no parentheses.
+ assert_eq!(read("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3");
+ assert_eq!(read("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)");
+ assert_eq!(read("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)");
+ // Each wrapper on its own: a Google-only one stays next to one that is unwrapped.
+ assert_eq!(
+ read("IFERROR(__xludf.DUMMYFUNCTION(\"A1*2\"),2)+IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)"),
+ "(A1*2)+IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)"
+ );
+ }
+
+ /// Text that doesn't parse, calls a function GridCraft lacks, or shapes that aren't exactly
+ /// the wrapper, are kept as they were, so the cell keeps the cached value as in Excel.
+ #[test]
+ fn google_sheets_dummy_function_kept_when_not_the_wrapper() {
+ for file in [
+ "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(\"),5)",
+ "IFERROR(__xludf.DUMMYFUNCTION(\"1+\"),5)+1",
+ // Google-only functions: GridCraft would show #NAME? instead of the cached value.
+ "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1,\"\"en\"\",\"\"fr\"\")\"),\"x\")",
+ "IFERROR(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)",
+ "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(QUERY(A1:B3,\"\"select B\"\"))\"),2)",
+ // A wrapper inside the text: only one level is ever unwrapped.
+ "IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)",
+ "IFERROR(__xludf.DUMMYFUNCTION(A1),5)",
+ "IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)",
+ "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))",
+ "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),5,6)",
+ "IFNA(__xludf.DUMMYFUNCTION(\"A1\"),5)",
+ "IFERROR(_xludf.DUMMYFUNCTION(\"A1\"),5)",
+ "__xludf.DUMMYFUNCTION(\"A1\")",
+ ] {
+ let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap());
+ assert_eq!(read(file), kept, "{file}");
+ assert_eq!(unwrap_google_sheets(&from_file(file), &test_known_function), None, "{file}");
+ }
+ // Inner text over the formula length limit.
+ let long = format!("IFERROR(__xludf.DUMMYFUNCTION(\"{}\"),5)", "1".repeat(gridcraft_formula::MAX_FORMULA_LEN + 1));
+ assert!(read(&long).contains("DUMMYFUNCTION"));
+ // Nesting that would go past the parser's depth limit once unwrapped is kept as well.
+ let deep = |n: usize, mid: &str| format!("{}{mid}{}", "(".repeat(n), ")".repeat(n));
+ let inner = deep(200, "1").replace('"', "\"\"");
+ let file = deep(200, &format!("1+IFERROR(__xludf.DUMMYFUNCTION(\"{inner}\"),5)"));
+ assert!(gridcraft_formula::parse(&file).is_ok());
+ let read = read(&file);
+ assert!(read.contains("DUMMYFUNCTION"));
+ assert!(gridcraft_formula::parse(&read).is_ok());
+ }
+
#[test]
fn let_lambda_params() {
assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)");
diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs
index b687c35dc..56c5661f4 100644
--- a/crates/xlsx/src/lib.rs
+++ b/crates/xlsx/src/lib.rs
@@ -46,7 +46,7 @@ use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
-pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited};
+pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited};
pub use ods::read_ods;
pub use package::MAX_PART;
pub use passthrough::rebase_links;
@@ -84,7 +84,22 @@ impl ReadReport {
/// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the
/// package relationships, so every main-part content type is accepted).
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
- read::read_xlsx(bytes)
+ read::read_xlsx(bytes, None)
+}
+
+/// [`read_xlsx`], and opens formulas from Google Sheets' XLSX export as the formulas written in
+/// Google Sheets (#2871).
+///
+/// Google writes a formula that uses a function it thinks Excel lacks as
+/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`; Excel shows the
+/// cached value. A cell formula holding that wrapper, whole or inside a larger formula, is read
+/// as the formula inside when it parses and `known` says GridCraft calculates every function it
+/// calls. Otherwise (a Google-only function such as `GOOGLETRANSLATE`, inner text that doesn't
+/// parse, or nesting past the parser's limit) the wrapper stays, so the cell keeps showing the
+/// cached value as in Excel. `known` is passed in because this crate doesn't depend on the
+/// function library.
+pub fn read_xlsx_with(bytes: &[u8], known: &dyn Fn(&str) -> bool) -> Result<(Workbook, ReadReport), IoError> {
+ read::read_xlsx(bytes, Some(known))
}
/// The part of a zip package that declares more than [`MAX_PART`] unpacked bytes, with that size:
diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs
index 1cce76b6d..3cc2a6655 100644
--- a/crates/xlsx/src/read.rs
+++ b/crates/xlsx/src/read.rs
@@ -3,7 +3,9 @@
use std::sync::Arc;
use gridcraft_core::{DateSystem, RangeRef};
-use gridcraft_model::{CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook};
+use gridcraft_model::{
+ CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook, WorkbookView,
+};
use crate::package::{Package, Rel};
use crate::styles::{StylesIn, read_styles};
@@ -32,6 +34,9 @@ pub struct Ctx<'a> {
pub cell_pictures: Vec