From 43a118c543cdfb7668848d6bb2031b509f277b57 Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 02:01:49 +0200 Subject: [PATCH 001/446] perf: fill-handle double-click scans a dense neighbour column ~2.4x faster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #210, which the reviewer correctly rejected: the stated cause (a sparse column with a stray distant value) doesn't hold, since the scan already stops at the first empty cell. This is a different, narrower fix that doesn't change behaviour for any input — it never truncates a long fill, unlike #210's cap — only how fast a genuinely long, densely-populated run is scanned. grid.rs's fill-handle double-click called Sheet::value_ref once per row to find where the neighbouring column's data ends. Each call does a band lookup + row lookup + column lookup (three BTreeMap probes) via CellStore::get. For a real dense run (e.g. a filled column near the 1,048,576-row sheet limit) that's a lot of point lookups on the UI thread in one frame: ~59ms for a 1,000,000-row run in a release build (measured with the new fill_handle_perf example), likely worse under wasm where GridCraft actually runs in the browser. - crates/model/src/store.rs: CellStore::contiguous_value_run walks each band's row BTreeMap with `range` (one in-order pass per band) instead of one `get` per row, so a dense run costs roughly one tree-traversal step per row instead of one binary search per row. Deliberately narrower than `has`/`get`: a stored cell always decides the row on its own (even with an empty value), matching what `value_ref`'s callers actually test for, not `has`'s "formula counts regardless of its cached value" (used for Ctrl+arrow navigation). - crates/model/src/sheet.rs: Sheet::last_contiguous_nonempty layers `spill` back in wherever contiguous_value_run finds no stored cell, exactly where plain `value_ref` would fall through to it. - crates/ui-egui/src/grid.rs: the fill-handle double-click now calls this once instead of looping over value_ref. Result: 999,999-row dense run, 59.2ms -> 24.7ms (2.40x), same answer (asserted in the example). Four new tests in sheet.rs, including a 300-trial property test against a naive value_ref-loop reference (spills, formula-with-empty-value, gaps, blanks) — all pass, plus the full existing suite (`cargo test -p gridcraft-model -p gridcraft-ui-egui -p gridcraft-engine`; the only failures are #210-era gridcraft-engine CSV tests already failing on main, unrelated to this change). Co-Authored-By: Claude Sonnet 5 --- crates/engine/examples/fill_handle_perf.rs | 47 ++++++++ crates/model/src/sheet.rs | 131 +++++++++++++++++++++ crates/model/src/store.rs | 44 ++++++- crates/ui-egui/src/grid.rs | 7 +- 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 crates/engine/examples/fill_handle_perf.rs diff --git a/crates/engine/examples/fill_handle_perf.rs b/crates/engine/examples/fill_handle_perf.rs new file mode 100644 index 000000000..ccdde36a3 --- /dev/null +++ b/crates/engine/examples/fill_handle_perf.rs @@ -0,0 +1,47 @@ +//! Times the fill-handle double-click scan against a densely-filled neighbour column: the old +//! row-by-row `value_ref` loop from `grid.rs` vs. `Sheet::last_contiguous_nonempty`. +//! `cargo run --release -p gridcraft-engine --example fill_handle_perf -- [rows]` + +use std::time::Instant; + +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use serde_json::json; + +fn main() { + let rows: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000); + let mut s = Session::new(); + s.new_workbook(); + let t = Instant::now(); + let chunk = 10_000u32; + let mut r = 0u32; + while r < rows { + let n = chunk.min(rows - r); + let vals: Vec = (0..n).map(|i| json!([(r + i) as f64])).collect(); + s.execute("range.setValues", json!({"range": format!("A{}", r + 1), "values": vals})).expect("set"); + r += n; + } + println!("fill A1:A{rows}: {:?}", t.elapsed()); + + let d = s.doc().expect("doc"); + let sh = d.wb.active().expect("sheet"); + let neighbour = 0u32; // column A + let cap = (rows - 1).min(999_999); + + let t = Instant::now(); + let mut last = 0u32; + let mut iters = 0u64; + while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap { + last += 1; + iters += 1; + } + let old = t.elapsed(); + println!("old row-by-row scan: {iters} iterations, {old:?} ({:.1} ns/iter)", old.as_nanos() as f64 / iters.max(1) as f64); + + let t = Instant::now(); + let new_last = sh.last_contiguous_nonempty(neighbour, 1, cap); + let new_t = t.elapsed(); + println!("new last_contiguous_nonempty: last={new_last}, {new_t:?} ({:.2}x faster)", old.as_secs_f64() / new_t.as_secs_f64().max(1e-12)); + + assert_eq!(last, new_last, "old and new scans must agree"); +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e05d3bfb5..67635ac74 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -177,6 +177,39 @@ impl Sheet { _ => self.spill.get(&c), } } + /// Last row (inclusive) of the maximal contiguous run of non-empty values in `col`, starting + /// at `from_row` and capped at `cap` (inclusive). `from_row - 1` when `from_row` itself is + /// empty (or `from_row > cap`), same as a cell-by-cell `value_ref` scan would find, but + /// without calling `value_ref` once per row: `CellStore::contiguous_value_run` walks each + /// band's row map directly for the common case (a run of stored cells), only falling back to + /// a per-row `spill` check where the stored run stops short of `cap`. Used by the fill-handle + /// double-click, where `col` can be densely populated for very long stretches. + pub fn last_contiguous_nonempty(&self, col: u32, from_row: u32, cap: u32) -> u32 { + if from_row > cap { + return from_row.saturating_sub(1); + } + let mut row = from_row; + loop { + if row > cap { + return cap; + } + let c = CellRef::new(row, col); + match self.cells.contiguous_value_run(c, cap) { + Some(last) => row = last + 1, + None => { + // A stored cell decides the row on its own, even with an empty value (see + // `contiguous_value_run`'s doc comment): only an unstored row falls through + // to `spill`, exactly like `value_ref`. + let qualifies = self.cells.get(c).is_none() && self.spill.get(&c).is_some_and(|v| !v.is_empty()); + if qualifies { + row += 1; + } else { + return row.saturating_sub(1); + } + } + } + } + } pub fn style_id(&self, c: CellRef) -> StyleId { if let Some(cell) = self.cells.get(c) { return cell.style; @@ -405,6 +438,7 @@ fn line_at(pos: f64, default: f64, custom: &BTreeMap, max: u32) - #[cfg(test)] mod tests { use super::*; + use crate::cell::Formula; #[test] fn line_index_matches_linear() { @@ -421,6 +455,103 @@ mod tests { } } + /// Reference implementation: one `value_ref` call per row, exactly the loop + /// `last_contiguous_nonempty` replaces (from `grid.rs`'s fill-handle double-click). + fn last_contiguous_nonempty_naive(s: &Sheet, col: u32, from_row: u32, cap: u32) -> u32 { + let mut row = from_row; + while row <= cap && s.value_ref(CellRef::new(row, col)).is_some_and(|v| !v.is_empty()) { + row += 1; + } + row.saturating_sub(1).max(from_row.saturating_sub(1)) + } + + /// Deterministic xorshift32 so failures reproduce without pulling in a `rand` dependency + /// just for this test. + struct Xorshift32(u32); + impl Xorshift32 { + fn next(&mut self) -> u32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 + } + fn below(&mut self, n: u32) -> u32 { + self.next() % n.max(1) + } + } + + #[test] + fn last_contiguous_nonempty_matches_naive() { + let mut rng = Xorshift32(0x9e3779b9); + for trial in 0..300u32 { + let mut s = Sheet::new("S"); + let rows = rng.below(400); + // A mix of directly-stored values, formulas (value empty but formula present), + // spill-only cells, blank-but-stored cells, and gaps, like a real sheet. + for r in 0..rows { + match rng.below(5) { + 0 => s.set_value(CellRef::new(r, 0), Value::Number(r as f64)), + 1 => s.set_cell(CellRef::new(r, 0), Cell { formula: Some(Arc::new(Formula::new("1"))), ..Default::default() }), + 2 => { + s.spill.insert(CellRef::new(r, 0), Value::Number(r as f64)); + } + 3 => { + // Stored but blank: does not count (matches `has_value`/`value_ref`). + s.set_value(CellRef::new(r, 0), Value::Text("".into())); + } + _ => {} // gap + } + } + for _ in 0..5 { + let from_row = rng.below(420); + let cap = from_row.saturating_add(rng.below(450)); + let got = s.last_contiguous_nonempty(0, from_row, cap); + let want = last_contiguous_nonempty_naive(&s, 0, from_row, cap); + assert_eq!(got, want, "trial {trial}: col 0, from_row {from_row}, cap {cap}, rows up to {rows}"); + } + } + } + + #[test] + fn last_contiguous_nonempty_dense_run() { + let mut s = Sheet::new("S"); + for r in 0..2000u32 { + s.set_value(CellRef::new(r, 2), Value::Number(r as f64)); + } + assert_eq!(s.last_contiguous_nonempty(2, 0, 1_999), 1999); + assert_eq!(s.last_contiguous_nonempty(2, 0, 1_000), 1000); // capped + assert_eq!(s.last_contiguous_nonempty(2, 500, 1_999), 1999); + assert_eq!(s.last_contiguous_nonempty(2, 2000, 3000), 1999); // nothing at from_row + assert_eq!(s.last_contiguous_nonempty(2, 2001, 3000), 2000); // from_row > cap: from_row - 1 + } + + #[test] + fn last_contiguous_nonempty_formula_with_empty_value_ends_the_run() { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 1), Value::Number(1.0)); + s.set_value(CellRef::new(1, 1), Value::Number(2.0)); + // row 2: a formula (e.g. `=""`) whose cached value is empty. A stored cell always + // decides the row on its own, exactly like `value_ref`: this does NOT count, same as + // `value_ref(...).is_some_and(|v| !v.is_empty())` would say for it, even though it has + // a formula (unlike `CellStore::has`, used for Ctrl+arrow navigation, which would). + s.set_cell(CellRef::new(2, 1), Cell { formula: Some(Arc::new(Formula::new("\"\""))), ..Default::default() }); + s.set_value(CellRef::new(3, 1), Value::Number(4.0)); // would extend the run if row 2 didn't stop it + assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 1); + assert_eq!(s.last_contiguous_nonempty(1, 2, 100), 1); // from_row itself is the stop + } + + #[test] + fn last_contiguous_nonempty_spill_extends_an_unstored_row() { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 1), Value::Number(1.0)); + // row 1: nothing stored, but a spill value from an array formula anchored elsewhere. + s.spill.insert(CellRef::new(1, 1), Value::Number(2.0)); + s.set_value(CellRef::new(2, 1), Value::Number(3.0)); + // row 3: a genuine gap (nothing stored, no spill). + assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 2); + assert_eq!(s.last_contiguous_nonempty(1, 3, 100), 2); // from_row itself is the gap + } + #[test] fn geometry() { let mut s = Sheet::new("S"); diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index d6a9c4561..ec9b6c98c 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -13,6 +13,10 @@ use crate::cell::Cell; const BAND: u32 = 64; +fn has_value(cell: &Cell) -> bool { + !cell.value.is_empty() || cell.formula.is_some() +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] struct Band { /// row → (col → cell) @@ -118,7 +122,45 @@ impl CellStore { } /// Last stored row in column `col` at or above `below_or_at` (for Ctrl+arrow navigation). pub fn has(&self, c: CellRef) -> bool { - self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some()) + self.get(c).is_some_and(has_value) + } + /// Last row (inclusive, capped at `cap`) of the maximal run of contiguous rows at or after + /// `from.row` that store a cell in `from.col` with a non-empty *value* in `from.col`. `None` + /// when `from` itself doesn't qualify. + /// + /// Deliberately narrower than `has`/`get` (which also count a formula with no cached value + /// yet, for Ctrl+arrow navigation): this matches `Sheet::value_ref`, whose callers test the + /// `Value` it returns with `!v.is_empty()` regardless of whether a formula produced it — a + /// formula cell with an empty cached value (`=""`, or not yet recalculated) is "empty" to + /// them. A stored cell always decides the row on its own either way (`value_ref` never falls + /// through to `spill` once a cell is stored there, whether or not its value is empty), so + /// `last_contiguous_nonempty` only needs to check `spill` where this returns `None`, i.e. + /// no cell is stored at `from.col` for that row at all. + /// + /// Walks each band's row map with `BTreeMap::range` (one in-order pass per band) instead of + /// one `get` per row, so a long, densely-populated run costs roughly one tree-traversal step + /// per row rather than one binary search per row. + pub fn contiguous_value_run(&self, from: CellRef, cap: u32) -> Option { + let mut last = None; + let mut expect = from.row; + while expect <= cap { + let band_key = expect / BAND; + let Some(band) = self.bands.get(&band_key) else { break }; + let band_hi = ((band_key + 1) * BAND - 1).min(cap); + let mut advanced = false; + for (r, cols) in band.rows.range(expect..=band_hi) { + if *r != expect || !cols.get(&from.col).is_some_and(|c| !c.value.is_empty()) { + return last; + } + last = Some(expect); + expect += 1; + advanced = true; + } + if !advanced { + break; + } + } + last } /// Positions whose cells differ between `self` and `other` (stored on one side only, or not /// equal). Bands both stores share are skipped, so comparing an edited copy with the store diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..7ac6a848c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2054,11 +2054,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Fill down as far as the neighbouring column goes. let src = sel.current(); let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 - { - last += 1; - } + let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); + let last = if src.end.row + 1 <= cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; if last > src.end.row { let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); From e7d590c8a434ef7acaf5f11674614a1b4dc11fbc Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 02:07:45 +0200 Subject: [PATCH 002/446] style: satisfy cargo fmt and clippy on the fill-handle perf change cargo fmt reformatted the new example's while-loop condition onto its own line; clippy's nonminimal_bool caught a negated is_some_and that reads more plainly as is_none_or. Co-Authored-By: Claude Sonnet 5 --- crates/engine/examples/fill_handle_perf.rs | 3 ++- crates/model/src/store.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/examples/fill_handle_perf.rs b/crates/engine/examples/fill_handle_perf.rs index ccdde36a3..0480c0afb 100644 --- a/crates/engine/examples/fill_handle_perf.rs +++ b/crates/engine/examples/fill_handle_perf.rs @@ -31,7 +31,8 @@ fn main() { let t = Instant::now(); let mut last = 0u32; let mut iters = 0u64; - while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap { + while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap + { last += 1; iters += 1; } diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index ec9b6c98c..658b898bf 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -149,7 +149,7 @@ impl CellStore { let band_hi = ((band_key + 1) * BAND - 1).min(cap); let mut advanced = false; for (r, cols) in band.rows.range(expect..=band_hi) { - if *r != expect || !cols.get(&from.col).is_some_and(|c| !c.value.is_empty()) { + if *r != expect || cols.get(&from.col).is_none_or(|c| c.value.is_empty()) { return last; } last = Some(expect); From 7ba3676f5fff269ddc9c3171e8336af0ddeea352 Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 02:52:07 +0200 Subject: [PATCH 003/446] fix: satisfy stricter clippy lints CI's newer stable toolchain catches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed with cargo clippy --workspace --all-targets -- -D warnings, not a test. The actual cause: I'd been validating locally against rustc/clippy 1.95.0 (the toolchain this machine had installed before now), while CI installs plain "stable" (1.99.0 as of this run) — two lints new since 1.95.0 fired there and nowhere in my local checks: - clippy::int_plus_one on `src.end.row + 1 <= cap` in grid.rs, simplified to `src.end.row < cap` (same meaning, no overflow risk change: row is a u32 and cap is already saturating-clamped below MAX_ROWS - 1, so src.end.row + 1 can't wrap). - clippy::disallowed_methods on the new fill_handle_perf example's three Instant::now() calls — the project's own lint from the time- panic fix (#125 history): reading the clock traps on wasm. This example is native-only (a `cargo run --release` benchmark, never built for wasm), so it gets the same #![allow(...)] perf.rs already carries, with the same justification. Updated the local toolchain to stable (rustup update stable) and re-ran cargo clippy --workspace --all-targets -- -D warnings clean before this push, to actually match what CI checks instead of guessing again. Co-Authored-By: Claude Sonnet 5 --- crates/engine/examples/fill_handle_perf.rs | 2 ++ crates/ui-egui/src/grid.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/examples/fill_handle_perf.rs b/crates/engine/examples/fill_handle_perf.rs index 0480c0afb..5e9ea423c 100644 --- a/crates/engine/examples/fill_handle_perf.rs +++ b/crates/engine/examples/fill_handle_perf.rs @@ -2,6 +2,8 @@ //! row-by-row `value_ref` loop from `grid.rs` vs. `Sheet::last_contiguous_nonempty`. //! `cargo run --release -p gridcraft-engine --example fill_handle_perf -- [rows]` +#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it + use std::time::Instant; use gridcraft_engine::Session; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 7ac6a848c..56fec08ee 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2055,7 +2055,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let src = sel.current(); let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); - let last = if src.end.row + 1 <= cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; + let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; if last > src.end.row { let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); From 76c9f4f2afd5326d321b06268444448a218ac212 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:09:43 -0400 Subject: [PATCH 004/446] Build: the ui-egui build script reads CARGO_MANIFEST_DIR when it runs env! fixes the path to contributors/ when the build script is compiled. After a checkout is moved or copied, the compiled script keeps looking in the old location: the About window loses its contributor list, and because the rerun-if-changed path no longer exists, Cargo reruns the script and rebuilds the UI crate on every build. Reading the variable at run time follows the checkout. --- crates/ui-egui/build.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..8a06c5932 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -13,7 +13,10 @@ use std::fmt::Write as _; use serde_json::Value; fn main() { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors"); + // Read when the script runs: `env!` would keep the path from when the script was compiled, + // which goes stale if the checkout moves (and a missing file reruns the script every build). + let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from).unwrap_or_default(); + let dir = manifest.join("../../contributors"); let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| { serde_json::from_str::(t).map_err(|e| e.to_string()) }) From c45212ac688e0118d7656668062e8fe08e662786 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:27 +0200 Subject: [PATCH 005/446] Refuse inserting cells that would push values off the worksheet (#580) --- crates/engine/src/cmd/sheet.rs | 9 +++++++++ crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index f05b77d91..14bb37f87 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -427,6 +427,15 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; if let Some(sh) = s.doc()?.wb.sheet(sheet) { check_table_shift(sh, &e)?; + // Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped. + let doomed = if shift == "right" { + RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1)) + } else { + RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)) + }; + if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) { + return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into())); + } } let right = shift == "right"; let out = edit(s, |cx| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..c1173fff4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1121,6 +1121,24 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() { assert!(e.to_string().contains("pushed off the end"), "{e}"); } +#[test] +fn inserting_cells_refuses_to_push_values_off_the_sheet() { + for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] { + let mut sess = s(); + sess.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap(); + let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err(); + assert!(e.to_string().contains("loss of data"), "{e}"); + assert_eq!(v(&sess, "A1"), Value::Number(1.0)); + assert_eq!(v(&sess, edge), Value::Number(2.0)); + // A value that still fits moves to the last valid cell. + let mut sess = s(); + sess.execute("cell.set", json!({"cell": last_fit, "input": "2"})).unwrap(); + sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap(); + assert_eq!(v(&sess, last_ok), Value::Number(2.0)); + } +} + #[test] fn autosum_over_whole_rows_is_one_undo_step_within_the_data() { // Found by the command-sequence fuzzer: AutoSum over rows `3:5` ran one `cell.set` per column From 0019c2c5523bde83615f07451988065593cca48d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:39 +0200 Subject: [PATCH 006/446] Make check --json list warning messages instead of a count (#526) --- apps/gridcraft-cli/src/check.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 9fc81e30a..4b1c65991 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -134,7 +134,8 @@ pub struct FileReport { pub path: String, /// GridCraft couldn't open the file: its message. pub error: Option, - pub warnings: usize, + /// Import warnings, in the order the importer produced them. + pub warnings: Vec, pub calc: CalcReport, pub round_trip: RoundTrip, } @@ -280,7 +281,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { return report; } }; - report.warnings = warnings.len(); + report.warnings = warnings; let mut fresh = cached.clone(); let mut calc = Calc::new(); calc.recalc_all(&mut fresh); @@ -1342,6 +1343,15 @@ mod tests { assert_eq!(validate_package(&own).unwrap(), Vec::::new()); } + #[test] + fn json_lists_warning_messages() { + let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; + let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; + let v = report_json(&[with, without], &Options::default()); + assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); + assert_eq!(v["files"][1]["warnings"], json!([])); + } + #[test] fn unopenable_files_are_reported() { let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); From 194a1204ab9575a09d8c428571bd35ead28477ed Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:43 +0200 Subject: [PATCH 007/446] Open the Header & Footer dialog on the sheet's saved header and footer (#523) --- crates/ui-egui/src/dialogs.rs | 6 +++++- crates/ui-egui/tests/interaction.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c68e..0560ad8f4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -368,7 +368,11 @@ impl Dialog { Text { key: "header", label: "Header (&L left, &C center, &R right; &P page, &N pages, &D date, &A sheet):" }, Text { key: "footer", label: "Footer:" }, ], - json!({"header": "", "footer": "&CPage &P of &N"}), + { + let sheet = app.session.active().and_then(|d| d.wb.active()); + let (header, footer) = sheet.map(|s| (s.print.header.clone(), s.print.footer.clone())).unwrap_or_default(); + json!({"header": header, "footer": footer}) + }, ), "pageSetup" => Dialog::form( "pageSetup", diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 709393389..ccbb30e95 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -630,3 +630,21 @@ fn rotated_wrapped_text_is_drawn_rotated_inside_its_cell() { assert!(clip.width() < 70.0, "clip spans one 6-character column, got {clip:?}"); } } + +#[test] +fn header_footer_dialog_opens_on_the_saved_header_and_footer() { + // #523: the dialog started from a fixed empty header and `&CPage &P of &N`, so OK replaced the sheet's text. + let mut s = blank(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft", "footer": "&CConfidential"})).unwrap(); + let mut h = harness(s); + h.state_mut().open_dialog("headerFooter", json!({})); + h.run_steps(2); + let d = h.state_mut().dialog.as_mut().unwrap(); + assert_eq!(d.values["header"], json!("&LLeft")); + assert_eq!(d.values["footer"], json!("&CConfidential")); + d.values.insert("header".into(), json!("&CNew")); + let vals = json!(d.values.clone()); + h.state_mut().run("pageLayout.headerFooter", vals).unwrap(); + let pr = &h.state().session.active().unwrap().wb.active().unwrap().print; + assert_eq!((pr.header.as_str(), pr.footer.as_str()), ("&CNew", "&CConfidential")); +} From da1d9d011673bc5e85944d6fcf3917ec44ad4167 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:04 +0200 Subject: [PATCH 008/446] Cap queued pointer events from ui.click/ui.drag control requests (#525) --- crates/ui-egui/src/control.rs | 90 +++++++++++++++++++++++++++-------- docs/control-protocol.md | 4 +- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6f6280ef7..2ec09b875 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -133,24 +133,42 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option { Some(geo.screen_cell_rect(sh, c).center()) } -fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) { +/// Most synthetic pointer events a control client may keep queued at once (the queue drains one batch per frame). +const MAX_QUEUED_POINTER_EVENTS: usize = 10_000; + +/// Refuses a request that would push the queued pointer events past [`MAX_QUEUED_POINTER_EVENTS`]; the queue is left unchanged. +fn pointer_budget(app: &SheetApp, extra: usize) -> Result<(), String> { + let queued: usize = app.synthetic.iter().map(Vec::len).sum(); + if queued.saturating_add(extra) > MAX_QUEUED_POINTER_EVENTS { + return Err(format!("too many pointer events (limit {MAX_QUEUED_POINTER_EVENTS} queued at once)")); + } + Ok(()) +} + +fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { + let count = count.max(1) as usize; + pointer_budget(app, count.saturating_mul(2).saturating_add(1))?; app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); - let mut batch = Vec::new(); - for _ in 0..count.max(1) { + let mut batch = Vec::with_capacity(count * 2); + for _ in 0..count { batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } app.synthetic.push_back(batch); + Ok(()) } -fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) { +fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { + let steps = steps.max(1); + pointer_budget(app, steps.saturating_add(3))?; app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); - for i in 1..=steps.max(1) { - let t = i as f32 / steps.max(1) as f32; + for i in 1..=steps { + let t = i as f32 / steps as f32; app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]); } app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]); + Ok(()) } /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. @@ -312,33 +330,38 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "ui.click" => { let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; - push_click(app, egui::pos2(f("x"), f("y")), button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p)); - ok(Json::Null) + let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; + match push_click(app, egui::pos2(f("x"), f("y")), button, count, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.drag" => { - push_drag( - app, - egui::pos2(f("x"), f("y")), - egui::pos2(f("toX"), f("toY")), - p.get("steps").and_then(Json::as_u64).unwrap_or(8) as usize, - modifiers(p), - ); - ok(Json::Null) + let steps = p.get("steps").and_then(Json::as_u64).unwrap_or(8).min(u64::from(u32::MAX)) as usize; + match push_drag(app, egui::pos2(f("x"), f("y")), egui::pos2(f("toX"), f("toY")), steps, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.cell.click" => { let Some(c) = s("cell").and_then(CellRef::parse) else { return err("missing `cell`") }; let Some(pos) = cell_center(app, c) else { return err("no grid yet") }; let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; - push_click(app, pos, button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p)); - ok(json!({"x": pos.x, "y": pos.y})) + let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; + match push_click(app, pos, button, count, modifiers(p)) { + Ok(()) => ok(json!({"x": pos.x, "y": pos.y})), + Err(e) => err(e), + } } "ui.cell.drag" => { let (Some(a), Some(b)) = (s("from").and_then(CellRef::parse), s("to").and_then(CellRef::parse)) else { return err("missing `from`/`to`"); }; let (Some(pa), Some(pb)) = (cell_center(app, a), cell_center(app, b)) else { return err("no grid yet") }; - push_drag(app, pa, pb, 8, modifiers(p)); - ok(Json::Null) + match push_drag(app, pa, pb, 8, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.resize" => { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(f("width").max(400.0), f("height").max(300.0)))); @@ -494,4 +517,31 @@ mod tests { assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); assert_eq!(filter_commands(sample(), None).len(), 3); } + + fn test_app() -> SheetApp { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + SheetApp::new(session, crate::Services::default()) + } + + #[test] + fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { + let mut app = test_app(); + let p = egui::pos2(20.0, 20.0); + let m = egui::Modifiers::NONE; + let btn = egui::PointerButton::Primary; + assert!(push_click(&mut app, p, btn, 2, m).is_ok()); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, MAX_QUEUED_POINTER_EVENTS, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + // Already queued input counts toward the budget. + assert!(push_click(&mut app, p, btn, MAX_QUEUED_POINTER_EVENTS / 2, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + // Normal requests still queue in full. + assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); + assert_eq!(app.synthetic.len(), 8); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a16208a38..d434fa996 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,8 +26,8 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | | `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | -| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points | -| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing) | +| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 pointer events queued | +| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | From 561299bfad8bb3c442796f22bf9c233dd3cf4594 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 22:59:53 +0800 Subject: [PATCH 009/446] sheet-navigation: keep boundary navigation on visible worksheets --- ROADMAP.md | 4 +- crates/engine/src/cmd/sheet.rs | 17 ++-- .../engine/tests/hidden_sheet_navigation.rs | 78 +++++++++++++++++++ docs/gaps.md | 5 +- docs/ui-parity.md | 5 +- 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 crates/engine/tests/hidden_sheet_navigation.rs diff --git a/ROADMAP.md b/ROADMAP.md index fbe48d35d..2b4f5dc76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (sheet navigation stays on visible worksheets at boundaries) · **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. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -177,6 +178,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index f05b77d91..590d129ad 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -890,17 +890,18 @@ fn activate(s: &mut Session, p: &Json) -> Result { Ok(json!({"sheet": i, "name": d.wb.active().map(|s| s.name.clone())})) } -fn step_sheet(s: &mut Session, dir: i64) -> Result { +fn step_sheet(s: &mut Session, dir: isize) -> Result { let d = s.doc()?; - let n = d.wb.sheets.len() as i64; - let mut i = d.wb.active_sheet as i64; - for _ in 0..n { - i = (i + dir).clamp(0, n - 1); - if d.wb.sheet(i as usize).is_some_and(|s| s.visibility == Visibility::Visible) { - break; + let mut i = d.wb.active_sheet; + while let Some(next) = i.checked_add_signed(dir) { + let Some(sheet) = d.wb.sheet(next) else { break }; + if sheet.visibility == Visibility::Visible { + s.doc_mut()?.wb_switch_sheet(next); + return ok(); } + i = next; } - s.doc_mut()?.wb_switch_sheet(i as usize); + // No visible sheet in this direction: keep the current sheet and its selection. ok() } diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/hidden_sheet_navigation.rs new file mode 100644 index 000000000..f457f4bdb --- /dev/null +++ b/crates/engine/tests/hidden_sheet_navigation.rs @@ -0,0 +1,78 @@ +//! Sheet navigation must never activate hidden sheets when it reaches a workbook boundary. + +use std::sync::Arc; + +use gridcraft_engine::{Session, model::Visibility}; +use serde_json::json; + +fn workbook(hidden: &[usize], active: usize) -> Session { + let mut session = Session::new(); + session.new_workbook(); + for name in ["Second", "Third", "Fourth"] { + session.execute("home.insertSheet", json!({"name": name})).unwrap(); + } + for sheet in hidden { + session.execute("sheet.hide", json!({"sheet": sheet})).unwrap(); + } + session.execute("sheet.activate", json!({"sheet": active})).unwrap(); + session.execute("selection.set", json!({"cell": "C7"})).unwrap(); + session +} + +#[test] +fn next_stays_on_the_last_visible_sheet() { + let mut session = workbook(&[2, 3], 1); + for _ in 0..2 { + session.execute("sheet.next", json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.selection.active.a1(), "C7"); + } +} + +#[test] +fn previous_stays_on_the_first_visible_sheet() { + let mut session = workbook(&[0, 1], 2); + for _ in 0..2 { + session.execute("sheet.previous", json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 2); + assert_eq!(doc.selection.active.a1(), "C7"); + } +} + +#[test] +fn navigation_skips_hidden_sheets_in_both_directions() { + let mut session = workbook(&[1, 2], 0); + session.execute("sheet.next", json!({})).unwrap(); + assert_eq!(session.active().unwrap().wb.active_sheet, 3); + session.execute("sheet.previous", json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 0); + assert_eq!(doc.selection.active.a1(), "C7"); +} + +#[test] +fn navigation_with_one_visible_sheet_is_a_noop() { + let mut session = workbook(&[0, 2, 3], 1); + for command in ["sheet.previous", "sheet.next"] { + session.execute(command, json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.selection.active.a1(), "C7"); + } +} + +#[test] +fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() { + let mut session = workbook(&[1, 3], 0); + // VeryHidden is an imported XLSX state; sheet.hide only creates Hidden sheets. + let wb = Arc::make_mut(&mut session.active_mut().unwrap().wb); + for index in [1, 3] { + Arc::make_mut(&mut wb.sheets[index]).visibility = Visibility::VeryHidden; + } + for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] { + session.execute(command, json!({})).unwrap(); + assert_eq!(session.active().unwrap().wb.active_sheet, expected); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 09870ba27..c8075f8e2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§6: iterative calculation landed) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (§9: hidden-sheet navigation at workbook boundaries fixed) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -84,6 +84,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 20–30 h for beta (25–40 h full). ### 9. Keyboard-first users: key tips, shortcuts, context menus (U) +- Next/previous sheet navigation now skips Hidden and VeryHidden worksheets without activating a + hidden sheet at the workbook boundary. Other keyboard gaps below remain. - **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #28 (selection), 59 command shortcuts vs Excel's 200+. - **Estimate:** 12–20 h. [ui-parity.md](ui-parity.md) @@ -160,6 +162,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §9: next/previous sheet commands retain the current visible sheet and selection when there is no visible destination | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | | 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbccc..a8b92655e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (next/previous sheet navigation stops at visible-sheet boundaries) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Next/previous sheet navigation no longer activates hidden worksheets at either workbook boundary; command tests cover Hidden, VeryHidden and selection retention | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From cf587ffe3b7aebcc197f12bd8c8ebabd841d22f6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:16:49 +0200 Subject: [PATCH 010/446] Names: a name over several ranges reads its top-level commas as unions (fixes #728) --- crates/calc/src/eval.rs | 2 +- crates/calc/src/tests.rs | 18 ++++++++++++++++++ crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/formulas.rs | 29 ++++++++++++++++++++++++++--- crates/engine/src/cmd/sheet.rs | 6 +++--- crates/formula/src/lib.rs | 2 +- crates/formula/src/parser.rs | 12 +++++++++++- crates/model/src/workbook.rs | 2 +- crates/xlsx/src/array_formula.rs | 2 +- 9 files changed, 63 insertions(+), 12 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39fd..e640c63e1 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -247,7 +247,7 @@ impl<'h> Evaluator<'h> { if let Some(def) = wb.name(bare, scope_sheet) { let text = def.formula.clone(); let scope = def.scope.unwrap_or(self.sheet); - return match gridcraft_formula::parse(&text) { + return match gridcraft_formula::parse_name(&text) { Ok(expr) => { // Relative references in a name are relative to A1: used here, they move by // this cell's offset from it. diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..5e62c29ea 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -515,6 +515,24 @@ fn names_and_sheets() { assert_eq!(t.num("A1"), 5.0); } +#[test] +fn name_referring_to_several_ranges() { + let mut t = T::new(); + // As Excel writes it in ``: a union with no parentheses around it. + let union = "Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"; + t.wb.names.push(gridcraft_model::DefinedName { name: "u".into(), scope: None, formula: union.into(), comment: String::new(), hidden: false }); + for (at, v) in [("A1", "1"), ("A2", "2"), ("C1", "5"), ("C2", "7")] { + t.set(at, v); + } + t.set("E1", "=SUM(u)"); + t.set("E2", "=AREAS(u)"); + t.set("E3", "=MIN(u)"); + t.set("E4", "=INDEX(u,2,1,2)"); + assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (15.0, 2.0, 1.0, 7.0)); + t.set("C2", "70"); + assert_eq!(t.num("E1"), 78.0); +} + #[test] fn legacy_array_formulas_fill_their_range() { let mut t = T::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b3b..7038bdb7a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1206,7 +1206,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & } } for n in wb.names.iter_mut() { - if let Ok(expr) = gridcraft_formula::parse(&n.formula) { + if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); n.formula = gridcraft_formula::print(&new); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 40df72df0..09a0a4644 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -265,7 +265,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result { let refers = match str_param(p, "refersTo") { // As in Excel, relative references are relative to the active cell; names keep them // relative to A1, as files do. - Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) { + Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) { Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => { let at = d.selection.active; gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64))) @@ -280,7 +280,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result { format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body) } }; - if gridcraft_formula::parse(&refers).is_err() { + if gridcraft_formula::parse_name(&refers).is_err() { return Err(EngineError::Other("There's a problem with this formula.".into())); } let comment = str_param(p, "comment").unwrap_or("").to_string(); @@ -355,7 +355,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc } for n in wb.names.iter_mut() { if n.scope.is_none_or(applies) - && let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename) + && let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename) { n.formula = gridcraft_formula::print(&e); } @@ -610,4 +610,27 @@ mod tests { let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap(); assert_eq!(r["refersTo"], "=Sheet1!$A$1"); } + + #[test] + fn names_referring_to_several_ranges() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap(); + // Excel writes and accepts a union in a name without parentheses. + let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap(); + assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"); + for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "E1"), json!(15.0)); + assert_eq!(v(&mut s, "E2"), json!(2.0)); + assert_eq!(v(&mut s, "E3"), json!(1.0)); + assert_eq!(v(&mut s, "E4"), json!(7.0)); + // Renaming the sheet follows every area, and the definition keeps Excel's form. + s.execute("sheet.rename", json!({"name": "Data"})).unwrap(); + let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone()); + assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2")); + assert_eq!(v(&mut s, "E1"), json!(15.0)); + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index f05b77d91..c30642e27 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -704,7 +704,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { *sc -= 1; } // Names referring to the sheet become #REF!. - if let Ok(e) = gridcraft_formula::parse(&n.formula) { + if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order); if ne != e { n.formula = gridcraft_formula::print(&ne); @@ -766,7 +766,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { } } for n in cx.wb.names.iter_mut() { - if let Ok(e) = gridcraft_formula::parse(&n.formula) { + if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)); } } @@ -805,7 +805,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { .iter() .filter(|nm| nm.scope == Some(i)) .map(|nm| { - let formula = gridcraft_formula::parse(&nm.formula) + let formula = gridcraft_formula::parse_name(&nm.formula) .map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name))) .unwrap_or_else(|_| nm.formula.clone()); gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() } diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index e193c3fb4..991db2ef8 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -12,7 +12,7 @@ pub mod printer; pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; pub use input::{FormulaLocale, parse_input, print_input}; -pub use parser::{ParseError, normalize_function_name, parse}; +pub use parser::{ParseError, normalize_function_name, parse, parse_name}; pub use printer::{print, print_r1c1, quote_sheet}; #[cfg(test)] diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7bb2..ee561bb53 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -23,12 +23,22 @@ pub fn parse(src: &str) -> Result { parse_syntax(src, crate::FormulaLocale::En) } +/// Parses a defined name's formula. Unlike a cell formula, a comma at the top level is the +/// union operator: Excel stores and shows a name over several ranges as `Sheet1!$A$1,Sheet1!$C$1`. +pub fn parse_name(src: &str) -> Result { + parse_top(src, crate::FormulaLocale::En, true) +} + pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result { + parse_top(src, locale, false) +} + +fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Result { let body = src.strip_prefix('=').unwrap_or(src); let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let toks = significant_spaces(toks); let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es }; - let e = p.expr(0, false)?; + let e = p.expr(0, allow_union)?; match p.peek() { Tok::Eof => Ok(e), t => Err(p.err(format!("unexpected {}", describe(t)))), diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 18e197194..b66ec8f0e 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -228,7 +228,7 @@ impl Workbook { } } for n in self.names.iter_mut() { - if let Ok(e) = gridcraft_formula::parse(&n.formula) { + if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { let ne = spell_calls(e.clone(), &lambdas); if ne != e { n.formula = gridcraft_formula::print(&ne); diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index caa5e8e9f..b2c09a749 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -57,7 +57,7 @@ impl Scan<'_> { None => (sheet, n.as_str()), }; if let Some(def) = self.wb.name(bare, scope) { - return match gridcraft_formula::parse(def.formula.trim_start_matches('=')) { + return match gridcraft_formula::parse_name(def.formula.trim_start_matches('=')) { Ok(expr) => self.expr(&expr, def.scope.unwrap_or(scope), array_context, depth + 1), Err(_) => Shape::array(), }; From f25bded4ef41bf6fd2123803bbac7f2dcbeb8892 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:12:20 +0200 Subject: [PATCH 011/446] Closing the window asks to save unsaved workbooks (fixes #838, fixes #841) --- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/dialogs.rs | 10 ++++- crates/ui-egui/src/lib.rs | 44 ++++++++++++++++++ crates/ui-egui/tests/file_menu.rs | 74 +++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b753b00e8..aa0069f53 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -376,6 +376,8 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.quit" => { + // Programmatic calls never open dialogs: quitting from here doesn't ask about unsaved work. + app.allow_close = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Json::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c68e..d226f4582 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -783,7 +783,7 @@ impl Dialog { } "pickList" => Dialog::custom("pickList", "Pick From List", json!({})), "saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})), - "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})), + "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})), "start" => Dialog::custom("start", "GridCraft", json!({})), "comments" => { app.grid.pane = None; @@ -1038,9 +1038,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } "saveChanges" => { ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); + // Asked while quitting: once answered, the next unsaved workbook is asked about. + let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); ui.horizontal(|ui| { if ui.button("Don't Save").clicked() { app.run_or_alert("file.close", json!({"force": true})); + if quitting { + app.continue_quit(ui.ctx()); + } open = false; } if ui.button("Cancel").clicked() { @@ -1051,6 +1056,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { app.run_or_alert("file.close", json!({"force": true})); + if quitting { + app.continue_quit(ui.ctx()); + } } open = false; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b19b2551..d6371b1bc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -247,6 +247,9 @@ pub struct SheetApp { /// Chart selected on the sheet (id). pub selected_chart: Option, pub started: f64, + /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next + /// window close goes through. + pub allow_close: bool, } impl SheetApp { @@ -288,6 +291,7 @@ impl SheetApp { shots: control::Shots::default(), selected_chart: None, started: now_ms(), + allow_close: false, } } @@ -877,6 +881,45 @@ impl SheetApp { } } + /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a + /// workbook has unsaved changes, and asks about them first. + fn guard_window_close(&mut self, ctx: &egui::Context) { + if self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + return; + } + // A cell still being edited is part of the workbook, as for File › Close. + if !self.commit_edit(0, 0, false, false) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + return; + } + if !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + match self.dialog.as_mut() { + // Already asking about a workbook (File › Close): answering it now goes on to quit. + Some(d) if d.name() == "saveChanges" => { + d.values.insert("quit".into(), json!(true)); + } + _ => self.continue_quit(ctx), + } + } + + /// Asks about the next unsaved workbook before quitting, or closes the window once none is left. + pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { + match self.session.documents().iter().position(|d| d.is_dirty()) { + Some(i) => { + self.session.set_active(i); + let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); + self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + } + None => { + self.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { // A keytip control request lives one frame: the ribbon consumes it during `ui`, and @@ -945,6 +988,7 @@ impl SheetApp { ctx.request_repaint(); } control::poll(self, ctx); + self.guard_window_close(ctx); // Files read asynchronously (web file picker, dropped files). let arrived: Vec<(String, Vec)> = self .services diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index c6ef2b212..195c43c37 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -171,3 +171,77 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file"); let _ = std::fs::remove_dir_all(&dir); } + +/// The window's close button (or Alt+F4, or the OS quitting the app): steps until the request +/// has been handled and returns the viewport commands the app answered with. +fn request_window_close(h: &mut egui_kittest::Harness<'static, SheetApp>) -> Vec { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + step_commands(h) +} + +/// Runs a few frames and gathers the commands sent to the window. +fn step_commands(h: &mut egui_kittest::Harness<'static, SheetApp>) -> Vec { + let mut out = Vec::new(); + for _ in 0..3 { + h.step(); + out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()); + } + out +} + +fn click_commands(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) -> Vec { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + step_commands(h) +} + +#[test] +fn closing_the_window_asks_about_every_unsaved_workbook() { + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + h.state_mut().run("file.new", serde_json::json!({})).unwrap(); + h.state_mut().run("cell.set", serde_json::json!({"cell": "B2", "input": "7"})).unwrap(); + h.run_steps(2); + + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "unsaved changes hold the window open: {sent:?}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = click_commands(&mut h, "Cancel"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel keeps the app open"); + assert!(h.state().dialog.is_none()); + assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks"); + + request_window_close(&mut h); + let sent = click_commands(&mut h, "Don't Save"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "the other workbook is still unsaved"); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "then it asks about the next one"); + let sent = click_commands(&mut h, "Don't Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "once every workbook is answered the window closes: {sent:?}"); + let sent = request_window_close(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::CancelClose), "the close that follows is let through"); +} + +#[test] +fn closing_the_window_can_save_first_and_a_saved_workbook_closes_at_once() { + let dir = std::env::temp_dir().join(format!("gridcraft-close-window-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Book.xlsx").to_string_lossy().into_owned(); + let mut h = harness(Services::default()); + h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap(); + h.run_steps(2); + let sent = request_window_close(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::CancelClose), "nothing unsaved: the window just closes"); + assert!(h.state().dialog.is_none()); + + // A cell still being typed is an unsaved change too. + h.input_mut().events.push(egui::Event::Text("123".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + let sent = click_commands(&mut h, "Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + let (wb, _) = gridcraft_engine::io::open_bytes("Book.xlsx", &std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0), "Save wrote the change before closing"); + let _ = std::fs::remove_dir_all(&dir); +} From 46b2fe2f7a85763afbb6f6718805753f94d80239 Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:50:48 -0400 Subject: [PATCH 012/446] Row, column and freeze commands act on the sheet they are given view.freezePanes, home.rowHeight, home.columnWidth, the AutoFit, Hide and Unhide row/column commands and home.defaultWidth always changed the active sheet: a `sheet` parameter was silently ignored, so a programmatic caller laying out a sheet that isn't active changed the wrong one, and `Sheet!` prefixes on `rows`/`cols` were refused as "not a range". They now take `sheet` (name or index) like the commands that already use target_sheet, accept a `Sheet!` prefix on `rows`/`cols` (and on Freeze Panes' `cell`, which was refused), and refuse a prefix that contradicts `sheet`. The selection belongs to the active sheet, so another sheet needs explicit lines or a cell. Freeze Top Row, Freeze First Column and Unfreeze Panes take `sheet` too. Without `sheet`, nothing changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/format.rs | 75 +++++++++++++++++++++------------ crates/engine/src/cmd/view.rs | 34 ++++++++++----- crates/engine/src/tests.rs | 41 ++++++++++++++++++ crates/mcp/src/tests.rs | 6 ++- 4 files changed, 116 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa120..0c0c546aa 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -156,7 +156,7 @@ pub fn specs() -> Vec { "Row Height…", ["Home", "Cells", "Format"], None, - "{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}", + "{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (points on screen)}", has_doc, row_height ), @@ -165,28 +165,34 @@ pub fn specs() -> Vec { "Column Width…", ["Home", "Cells", "Format"], None, - "{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}", + "{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (points) | chars: 8.43}", has_doc, col_width ), - cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows), - cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols), - cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width), - cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide( + cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows), + cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols), + cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64}", has_doc, default_width), + cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide( s, p, true, true )), - cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide( - s, p, false, true - )), - cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide( - s, p, true, false - )), + cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| { + hide(s, p, false, true) + }), + cmd!( + "home.unhideRows", + "Unhide Rows", + ["Home", "Cells", "Format", "Hide & Unhide"], + Some("Cmd+Shift+9"), + "{sheet?, rows?}", + has_doc, + |s, p| hide(s, p, true, false) + ), cmd!( "home.unhideColumns", "Unhide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+0"), - "{cols?}", + "{sheet?, cols?}", has_doc, |s, p| hide(s, p, false, false) ), @@ -921,19 +927,37 @@ fn cell_style(s: &mut Session, p: &Json) -> Result { // ---------------------------------------------------------------- sizes -fn line_targets(s: &Session, p: &Json, rows: bool) -> Result> { +/// The sheet and the row (or column) spans a size or visibility command works on. The lines are +/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the +/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active +/// sheet, so another sheet needs explicit lines. +fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { let key = if rows { "rows" } else { "cols" }; + let mut sheet = target_sheet(s, p)?; if let Some(t) = str_param(p, key) { + let (prefix, body) = split_sheet(t); + if let Some(name) = prefix { + let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?; + if p.get("sheet").is_some() && named != sheet { + return Err(bad(key, format!("`{t}` names a sheet other than `sheet`"))); + } + sheet = named; + } // A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection. - return t + let lines = body .split(',') .map(|part| { let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }) }) - .collect(); + .collect::>>()?; + return Ok((sheet, lines)); } - Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()) + let d = s.doc()?; + if sheet != d.wb.active_sheet { + return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet"))); + } + Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())) } fn row_height(s: &mut Session, p: &Json) -> Result { @@ -941,9 +965,8 @@ fn row_height(s: &mut Session, p: &Json) -> Result { if !(0.0..=546.0).contains(&h) { return Err(EngineError::Other("Row height must be between 0 and 409 points.".into())); } - let lines = line_targets(s, p, true)?; + let (si, lines) = line_targets(s, p, true)?; edit(s, |cx| { - let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; for (a, b) in lines { for r in a..=b.min(a.saturating_add(1_048_575)) { @@ -966,9 +989,8 @@ fn col_width(s: &mut Session, p: &Json) -> Result { if !(0.0..=1800.0).contains(&w) { return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into())); } - let lines = line_targets(s, p, false)?; + let (si, lines) = line_targets(s, p, false)?; edit(s, |cx| { - let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; for (a, b) in lines { for c in a..=b { @@ -1015,9 +1037,8 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 { } fn autofit_cols(s: &mut Session, p: &Json) -> Result { - let lines = line_targets(s, p, false)?; + let (si, lines) = line_targets(s, p, false)?; edit(s, |cx| { - let si = cx.sheet_index(); let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) }; let mut widths = Vec::new(); let used = sh.used_range(); @@ -1054,9 +1075,8 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result { } fn autofit_rows(s: &mut Session, p: &Json) -> Result { - let lines = line_targets(s, p, true)?; + let (si, lines) = line_targets(s, p, true)?; edit(s, |cx| { - let si = cx.sheet_index(); for (a, b) in lines { let b = b.min(a + 100_000); if let Some(sh) = cx.wb.sheet_mut(si) { @@ -1075,17 +1095,16 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result { fn default_width(s: &mut Session, p: &Json) -> Result { let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?; + let si = target_sheet(s, p)?; edit(s, |cx| { - let si = cx.sheet_index(); cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32; Ok(Json::Null) }) } fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result { - let lines = line_targets(s, p, rows)?; + let (si, lines) = line_targets(s, p, rows)?; edit(s, |cx| { - let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; for (a, b) in lines { // Unhiding a single selected line also reveals hidden neighbours (Excel needs the diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index edcacf57d..8c9389543 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -13,16 +13,25 @@ pub fn specs() -> Vec { "Freeze Panes", ["View", "Window", "Freeze Panes"], None, - "{cell?: \"B2\" (rows above and columns left of it freeze)}", + "{sheet?, cell?: \"B2\" (rows above and columns left of it freeze); without `cell`, unfreezes a frozen sheet}", has_doc, freeze_panes ), - cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, Some((1, 0)))), - cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze( + cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze( s, + target_sheet(s, p)?, + Some((1, 0)) + )), + cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze( + s, + target_sheet(s, p)?, Some((0, 1)) )), - cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, None)), + cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze( + s, + target_sheet(s, p)?, + None + )), cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")), cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")), cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), @@ -113,23 +122,28 @@ pub fn specs() -> Vec { } fn freeze_panes(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; if sh.freeze.is_some() && p.get("cell").is_none() { - return set_freeze(s, None); + return set_freeze(s, si, None); } - let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let at = match cell_param_on(s, p, "cell", si)? { + Some(c) => c, + None if si == d.wb.active_sheet => d.selection.active, + // The selection belongs to the active sheet. + None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")), + }; let top = sh.view_top_left; let rows = at.row.saturating_sub(top.row); let cols = at.col.saturating_sub(top.col); let _ = (rows, cols); let (rows, cols) = (at.row, at.col); - set_freeze(s, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) }) + set_freeze(s, si, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) }) } -fn set_freeze(s: &mut Session, f: Option<(u32, u32)>) -> Result { +fn set_freeze(s: &mut Session, si: usize, f: Option<(u32, u32)>) -> Result { edit(s, |cx| { - let si = cx.sheet_index(); cx.sheet_mut(si)?.freeze = f; Ok(json!({"freeze": f})) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..5db59594d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2636,6 +2636,47 @@ fn row_height_and_column_width_take_a_list_of_areas() { assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing"); } +/// Programmatic callers lay out a sheet that isn't the active one: `sheet` (or a `Sheet!` prefix on +/// `rows`, `cols` or `cell`) picks it, instead of being ignored in favour of the active sheet. +#[test] +fn line_sizes_and_freeze_panes_take_a_sheet() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let sheet = |s: &Session, i: usize| s.doc().unwrap().wb.sheets[i].clone(); + let before = sheet(&s, 0); + + s.execute("view.freezePanes", json!({"sheet": "Data", "cell": "A2"})).unwrap(); + s.execute("home.columnWidth", json!({"sheet": "Data", "cols": "A:A", "width": 150})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "Data!B:B,D:D", "chars": 20})).unwrap(); + s.execute("home.rowHeight", json!({"sheet": 1, "rows": "1:1", "height": 40})).unwrap(); + s.execute("home.hideColumns", json!({"sheet": "Data", "cols": "E:E"})).unwrap(); + s.execute("home.defaultWidth", json!({"sheet": "Data", "width": 80})).unwrap(); + let data = sheet(&s, 1); + assert_eq!(data.freeze, Some((1, 0))); + assert_eq!((data.col_width(0), data.col_width(1), data.col_width(3)), (150.0, 145.0, 145.0)); + assert_eq!(data.row_height(0), 40.0); + assert!(data.cols.get(&4).is_some_and(|c| c.hidden)); + assert_eq!(data.default_col_width, 80.0); + assert_eq!(sheet(&s, 0), before, "the active sheet is untouched"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + + s.execute("view.freezePanes", json!({"cell": "Data!B3"})).unwrap(); + assert_eq!(sheet(&s, 1).freeze, Some((2, 1))); + s.execute("view.unfreezePanes", json!({"sheet": "Data"})).unwrap(); + s.execute("view.freezeTopRow", json!({"sheet": "Data"})).unwrap(); + assert_eq!(sheet(&s, 1).freeze, Some((1, 0))); + + // The selection belongs to the active sheet: another sheet needs explicit lines or a cell, and + // a prefix can't contradict `sheet`. + assert!(s.execute("home.columnWidth", json!({"sheet": "Data", "width": 10})).is_err()); + s.execute("view.unfreezePanes", json!({"sheet": "Data"})).unwrap(); + assert!(s.execute("view.freezePanes", json!({"sheet": "Data"})).is_err()); + assert!(s.execute("home.columnWidth", json!({"sheet": "Sheet1", "cols": "Data!B:B", "width": 10})).is_err()); + assert!(s.execute("home.rowHeight", json!({"rows": "Nope!1:1", "height": 10})).is_err()); + assert_eq!(sheet(&s, 0), before); +} + #[test] fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() { // Issue #348: Excel slides lined-up areas together; formulas travel as their values. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eb1f20503..ce915d1c1 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -463,7 +463,6 @@ fn other_sheet_prefix_is_error() { ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); fails(&mut s, "set_cell", json!({"cell": "Nope!B2", "input": "x"})); // A command that cannot honour a sheet prefix must not drop it and use the active sheet. - cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"})); cmd_fails(&mut s, "formulas.tracePrecedents", json!({"cell": "Other!B2"})); ok(&mut s, "set_cell", json!({"cell": "Sheet1!B2", "input": "here"})); assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "here"); @@ -495,7 +494,10 @@ fn cross_sheet_cell_refs_resolve() { // The active sheet may be a quoted name with spaces. ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": "My Sheet"}})); ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "'My Sheet'!B2"}})); - cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"})); + // Freeze Panes honours another sheet's prefix (or `sheet`) and leaves the active sheet alone. + ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "Other!C4"}})); + let sheets = ok(&mut s, "inspect_workbook", json!({}))["sheets"].clone(); + assert_eq!((sheets[1]["freeze"].clone(), sheets[2]["freeze"].clone()), (json!([3, 2]), json!([1, 1])), "{sheets}"); // A prefix naming a different sheet than the `sheet` argument is an error, not resolved in favour of either. cmd_fails(&mut s, "cell.get", json!({"cell": "Other!B3", "sheet": "Sheet1"})); } From 9a816f80f7c635e05f8b5054bacae01385e3b09d Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:54:25 -0400 Subject: [PATCH 013/446] Format: Row Height and Column Width say pixels, and take points like Excel home.rowHeight and home.columnWidth take pixels at 96 dpi, the model's unit (header drags and the Row Height dialog send pixels), but their parameter docs said points. An agent asking for a 30 pt row got `ht="22.5"` in the file, and the range check accepted 500 while its message said "between 0 and 409 points". - The params docs say pixels: `height: 20 (pixels) | points: 15`, `width: 64 (pixels) | chars: 8.43`, and `{width: 64 (pixels)}` for home.defaultWidth. Pixel values behave as before. - home.rowHeight takes `points`, Excel's unit (Row Height dialog, `ht` in the file), as home.columnWidth already takes `chars`. - Range errors give the limit in the unit passed: 546 pixels (409.5 points), 409.5 points, 1800 pixels, 255 characters. `chars` is checked against 255, Excel's limit and the widest column a file holds (it went to 256.4 through the pixel limit). - The model's size comments say pixels. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/format.rs | 49 +++++++++++++++++++++++---------- crates/engine/src/tests.rs | 42 ++++++++++++++++++++++++++++ crates/model/src/features.rs | 2 +- crates/model/src/sheet.rs | 4 +-- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa120..5b550d480 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -156,7 +156,7 @@ pub fn specs() -> Vec { "Row Height…", ["Home", "Cells", "Format"], None, - "{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}", + "{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (pixels) | points: 15}", has_doc, row_height ), @@ -165,13 +165,13 @@ pub fn specs() -> Vec { "Column Width…", ["Home", "Cells", "Format"], None, - "{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}", + "{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (pixels) | chars: 8.43}", has_doc, col_width ), cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows), cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols), - cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width), + cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64 (pixels)}", has_doc, default_width), cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide( s, p, true, true )), @@ -936,11 +936,25 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result> { Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()) } +/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height) +/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs +/// and of the file (`ht` is in points). fn row_height(s: &mut Session, p: &Json) -> Result { - let h = f64_param(p, "height").ok_or_else(|| bad("home.rowHeight", "missing `height`"))?; - if !(0.0..=546.0).contains(&h) { - return Err(EngineError::Other("Row height must be between 0 and 409 points.".into())); - } + let h = match (f64_param(p, "height"), f64_param(p, "points")) { + (Some(h), _) => { + if !(0.0..=546.0).contains(&h) { + return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into())); + } + h + } + (None, Some(pt)) => { + if !(0.0..=409.5).contains(&pt) { + return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into())); + } + pt * 4.0 / 3.0 + } + _ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")), + }; let lines = line_targets(s, p, true)?; edit(s, |cx| { let si = cx.sheet_index(); @@ -959,13 +973,20 @@ fn row_height(s: &mut Session, p: &Json) -> Result { fn col_width(s: &mut Session, p: &Json) -> Result { let w = match (f64_param(p, "width"), f64_param(p, "chars")) { - (Some(w), _) => w, - (None, Some(c)) => chars_to_points(c), - _ => return Err(bad("home.columnWidth", "missing `width`")), + (Some(w), _) => { + if !(0.0..=1800.0).contains(&w) { + return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into())); + } + w + } + (None, Some(c)) => { + if !(0.0..=255.0).contains(&c) { + return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into())); + } + chars_to_points(c) + } + _ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")), }; - if !(0.0..=1800.0).contains(&w) { - return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into())); - } let lines = line_targets(s, p, false)?; edit(s, |cx| { let si = cx.sheet_index(); @@ -981,7 +1002,7 @@ fn col_width(s: &mut Session, p: &Json) -> Result { }) } -/// Excel column width in characters (of the default font's digit width, 7 px) → points. +/// Excel column width in characters (of the default font's digit width, 7 px) → pixels. pub fn chars_to_points(c: f64) -> f64 { if c <= 0.0 { 0.0 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..2ffb096fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2636,6 +2636,48 @@ fn row_height_and_column_width_take_a_list_of_areas() { assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing"); } +#[test] +fn row_height_and_column_width_take_pixels_or_excel_units() { + // `height` and `width` are pixels at 96 dpi (the model's unit); `points` and `chars` are + // Excel's units, those of its Row Height and Column Width dialogs. A row is saved in points. + let mut s = s(); + s.execute("home.rowHeight", json!({"rows": "1:1", "points": 30})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "2:2", "height": 30})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "3:3", "points": 409.5})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 8.43})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "width": 100})).unwrap(); + let sheet = s.doc().unwrap().wb.active().unwrap(); + assert_eq!([sheet.row_height(0), sheet.row_height(1), sheet.row_height(2)], [40.0, 30.0, 546.0]); + assert_eq!([sheet.col_width(0), sheet.col_width(1)], [64.0, 100.0]); + let bytes = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); + for row in [r#" u64 { /// Outline grouping info for a row or column. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct LineInfo { - /// Size in points; `None` = default (rows auto-fit to content). + /// Size in pixels at 96 dpi; `None` = default (rows auto-fit to content). pub size: Option, pub hidden: bool, pub outline: u8, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e05d3bfb5..5b5d1e294 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -64,7 +64,7 @@ pub struct Sheet { pub spill_ranges: BTreeMap, pub cols: BTreeMap, pub rows: BTreeMap, - /// Default column width and row height in points. + /// Default column width and row height in pixels at 96 dpi (a file stores row heights in points). pub default_col_width: f32, pub default_row_height: f32, pub merges: Vec, @@ -113,7 +113,7 @@ pub struct Sheet { pub preserved: Option>, } -/// Default width of a column (points) and height of a row (points) for the default font. +/// Default width of a column and height of a row (pixels at 96 dpi) for the default font. pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; From 151a279da835ded9150ac278ae4c1cb790be357a Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:58:21 -0400 Subject: [PATCH 014/446] Conditional formats: hex colours in a custom style, no silently empty rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit home.conditionalFormat deserialized `style` straight into the model's Style, so a programmatic caller had to write colours as {"Rgb": 16773836}: "#FFF2CC", which format_range, colorScale and dataBar take, was refused with a serde message naming internal types. A key the struct doesn't have (`style: {preset: …}`, `fillColor`) was dropped, which saved a rule with an empty format that matches and shows nothing. And a cellIs `value` already written as a string literal ("\"Closed\"", as in the file and openpyxl) was quoted again into the text "Closed" with its quotes, so it never matched. `style` colours (font, fill fg/bg, borders) may now be "#RRGGBB", {theme, tint} or "auto", and `fill` may be a colour alone (a solid fill); a fill colour with no pattern is solid. Unknown keys and a non-object `style` are refused. A cellIs value that is one string literal is kept. The model's own shape keeps working. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 69 +++++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 58 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5eebf..0d58b0da5 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -179,7 +179,7 @@ pub fn specs() -> Vec { "Conditional Formatting", ["Home", "Styles"], None, - "{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}", + "{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}", has_doc, add_cf ), @@ -1266,6 +1266,64 @@ fn preset_style(name: &str) -> Style { s } +/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled. +fn is_string_literal(v: &str) -> bool { + v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"')) +} + +/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`, +/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be +/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a +/// colour alone (a solid fill). An unknown key is an error rather than dropped: dropped, it left a +/// rule with an empty format that matches and shows nothing. +fn cf_style_json(st: &Json) -> Result { + const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"]; + let err = |m: String| bad("home.conditionalFormat", m); + let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) }; + let mut st = obj.clone(); + if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) { + let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" }; + return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", ")))); + } + // A colour written the way the other formatting commands take it, as the model stores it. + let color = |v: &mut Json, what: &str| -> Result<()> { + if v.is_string() || v.get("theme").is_some() { + let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?; + *v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?; + } + Ok(()) + }; + if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut) + && let Some(c) = f.get_mut("color") + { + color(c, "font.color")?; + } + if let Some(fill) = st.get_mut("fill") { + if fill.is_string() || fill.get("theme").is_some() { + *fill = json!({"pattern": "Solid", "fg": fill.clone()}); + } + if let Some(f) = fill.as_object_mut() { + for k in ["fg", "bg"] { + if let Some(c) = f.get_mut(k) { + color(c, &format!("fill.{k}"))?; + } + } + // A colour with no pattern is a solid fill, not an invisible one. + if f.contains_key("fg") && !f.contains_key("pattern") { + f.insert("pattern".into(), json!("Solid")); + } + } + } + if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) { + for (side, line) in b.iter_mut() { + if let Some(c) = line.get_mut("color") { + color(c, &format!("borders.{side}.color"))?; + } + } + } + Ok(Json::Object(st)) +} + fn add_cf(s: &mut Session, p: &Json) -> Result { let ranges = target_ranges(s, p)?; let sheet = target_sheet(s, p)?; @@ -1275,9 +1333,10 @@ fn add_cf(s: &mut Session, p: &Json) -> Result { }; let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string); let style = Box::new(match rule.get("style") { - Some(st) if st.is_object() => { + Some(st) if !st.is_null() => { + let st = cf_style_json(st)?; let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?; - serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))? + serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))? } _ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")), }); @@ -1316,8 +1375,12 @@ fn add_cf(s: &mut Session, p: &Json) -> Result { let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default(); let b = rule.get("value2").map(super::edit::json_to_input); let quote = |v: String| { + // A number, a formula, or text already written as a string literal (`"Closed"`, as + // in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted. if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') { v.trim_start_matches('=').to_string() + } else if is_string_literal(&v) { + v } else { format!("\"{}\"", v.replace('"', "\"\"")) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..7b3fff840 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2612,6 +2612,64 @@ fn icon_set_thresholds_from_files_pick_the_icon() { assert_eq!([icon(&s, "A1"), icon(&s, "A2"), icon(&s, "A3"), icon(&s, "A4")], [Some(0), Some(1), Some(1), Some(2)]); } +/// `home.conditionalFormat` styles take colours the way the other formatting commands do, refuse +/// keys they would drop, and keep a text criterion already written as a string literal. +#[test] +fn conditional_format_styles_take_hex_colours_and_quoted_values() { + use gridcraft_model::{CfRule, Color, Fill, PatternType}; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Open"], ["Closed"], ["Blocked"]]})).unwrap(); + let rule = |s: &Session, i: usize| s.doc().unwrap().wb.active().unwrap().cond_formats[i].rule.clone(); + let style = |r: &CfRule| match r { + CfRule::Expression { style, .. } | CfRule::CellIs { style, .. } => (**style).clone(), + other => panic!("{other:?}"), + }; + s.execute( + "home.conditionalFormat", + json!({"range": "A1:A3", "rule": {"type": "expression", "formula": "=A1=\"Open\"", "style": {"fill": "#FFF2CC", "font": {"bold": true, "color": "#9C0006"}}}}), + ) + .unwrap(); + let st = style(&rule(&s, 0)); + assert_eq!(st.fill, Fill::solid(Color::Rgb(0xFFF2CC))); + assert_eq!((st.font.bold, st.font.color), (true, Color::Rgb(0x9C0006))); + // A fill colour without a pattern is solid; the model's own shape still works. + s.execute( + "home.conditionalFormat", + json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": {"fill": {"fg": "C6EFCE"}}}}), + ) + .unwrap(); + assert_eq!(style(&rule(&s, 0)).fill, Fill { pattern: PatternType::Solid, fg: Color::Rgb(0xC6EFCE), bg: Color::Auto }); + let internal = json!({"fill": {"pattern": "Solid", "fg": {"Rgb": 16773836}}, "borders": {"left": {"style": "Thin", "color": "#C00000"}}}); + s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": internal}})).unwrap(); + let st = style(&rule(&s, 0)); + assert_eq!((st.fill.fg, st.borders.left.color), (Color::Rgb(0xFFF2CC), Color::Rgb(0xC00000))); + // Keys that would be dropped, leaving a rule that shows nothing, are refused. + let n = s.doc().unwrap().wb.active().unwrap().cond_formats.len(); + for bad in [json!({"preset": "greenFill"}), json!({"fillColor": "#FFF2CC"}), json!({"fill": "#nothex"}), json!("bold")] { + let e = s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": bad}})); + assert!(e.is_err(), "{bad} should be refused"); + } + assert_eq!(s.doc().unwrap().wb.active().unwrap().cond_formats.len(), n); + + // `"Closed"` (a string literal, as in the file) and `Closed` both match the text Closed. + for value in ["\"Closed\"", "Closed"] { + s.execute( + "home.conditionalFormat", + json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": "equal", "value": value, "preset": "greenFill"}}), + ) + .unwrap(); + assert!(matches!(rule(&s, 0), CfRule::CellIs { ref a, .. } if a == "\"Closed\""), "{value}"); + let wb = s.doc().unwrap().wb.clone(); + let mut cache = crate::cf::CfCache::new(); + let mut fill = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).map(|st| st.fill.fg); + assert_eq!(fill("A2"), Some(Color::Rgb(0xC6EFCE)), "{value}"); + s.execute("edit.undo", json!({})).unwrap(); + } + // Text with quotes inside that isn't one literal is still quoted as text. + s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "cellIs", "operator": "equal", "value": "say \"hi\""}})).unwrap(); + assert!(matches!(rule(&s, 0), CfRule::CellIs { ref a, .. } if a == "\"say \"\"hi\"\"\"")); +} + #[test] fn row_height_and_column_width_take_a_list_of_areas() { // Issue #341: header drags on a multi-area selection size every selected line in one step. From 180928c001c121a6b66ac6476b5bafb6e3895533 Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:09 -0400 Subject: [PATCH 015/446] Data validation: a list typed without quotes is saved as a quoted list data.validation {type: "list", formula1: "Open,In progress,Closed"}, which is how Excel's Source box takes a list, worked inside GridCraft but was saved as Open,In progress,Closed. In a file an inline list is a string literal, "Open,In progress,Closed" (what Excel writes and what our own reader expects); unquoted, formula1 reads as a formula of names. - validation quotes such a source when the rule is created, doubling inner quotes, using the test list_items already used to tell a literal list from a reference or a name (now is_literal_list). A source typed with = (a formula), a reference, a name, a sheet-qualified range and an already quoted list are stored as before. - list_items reads a quoted list as a string literal: "" inside stands for one quote, so an item with a quote reads back the same after the change and from a file. Creating the rule is the one place that still knows whether the user typed =, so the XLSX writer is left alone: it can't tell OFFSET(...) entered as a formula from a literal list, and a rule read from a file is already quoted. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/data.rs | 94 ++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3baa6..f268f40e0 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -929,11 +929,13 @@ fn validation(s: &mut Session, p: &Json) -> Result { "custom" => ValidationKind::Custom, _ => ValidationKind::Any, }; + let f1 = str_param(p, "formula1").unwrap_or(""); + let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; let dv = Validation { ranges: ranges.clone(), kind, op: op_param(str_param(p, "operator")), - f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(), + f1, f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), @@ -957,14 +959,36 @@ fn validation(s: &mut Session, p: &Json) -> Result { }) } +/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather +/// than a reference or a name to evaluate. +fn is_literal_list(f: &str) -> bool { + f.starts_with('"') + || (!f.contains('!') + && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) +} + +/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's +/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves +/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a +/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. +fn list_source(f: &str) -> String { + let t = f.trim(); + if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { + return f.trim_start_matches('=').to_string(); + } + format!("\"{}\"", t.replace('"', "\"\"")) +} + /// List entries of a list validation (literal or from a range). pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { let f = dv.f1.trim(); - if f.starts_with('"') - || (!f.contains('!') - && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) - { - return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); + if is_literal_list(f) { + // A quoted list is a string literal: `""` inside stands for one `"`. + let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { + Some(body) => body.replace("\"\"", "\""), + None => f.trim_matches('"').to_string(), + }; + return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); match gridcraft_calc::evaluate(wb, sheet, at, f) { @@ -1381,4 +1405,62 @@ mod tests { assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); } + + #[test] + fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { + // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. + // References, names and formulas stay unquoted, and every rule survives a save and reopen. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); + let rules = [ + ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), + ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), + ("C1", " 1,2,3 ", r#""1,2,3""#), + ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), + ("E1", "=$K$1:$K$3", "$K$1:$K$3"), + ("F1", "$K$1:$K$3", "$K$1:$K$3"), + ("G1", "=Colors", "Colors"), + ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), + ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), + ]; + for (cell, source, _) in rules { + s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); + } + let accepted = [ + ("A1", "In progress"), + ("B1", "Closed"), + ("C1", "2"), + ("D1", r#"Say "hi""#), + ("E1", "Green"), + ("F1", "Blue"), + ("G1", "Red"), + ("H1", "Green"), + ]; + let check = |wb: &Workbook| { + for (cell, input) in accepted { + let at = CellRef::parse(cell).unwrap(); + assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); + assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); + } + }; + let wb = s.doc().unwrap().wb.clone(); + check(&wb); + + let sheet_xml = |wb: &Workbook| { + let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); + xml + }; + let xml = sheet_xml(&wb); + for (cell, _, saved) in rules { + assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); + } + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + check(&reopened); + assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); + } } From 1a6979c948ccd8c727546c1fa2e6ab234d6f1eb8 Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:32:31 -0400 Subject: [PATCH 016/446] Data validation: refuse an unknown type, operator or error style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data.validation took any `type`, `operator` or `errorStyle` and fell back to a default: a typo such as `type: "lst"` saved an "any value" rule (`type="none"`), which validates nothing, and an unknown operator became `between`. Each is now an error that lists the accepted values; `any` stays a type, and the operator aliases (`greaterThan`…) still work. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/data.rs | 61 +++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index f268f40e0..46cc12b1e 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -927,7 +927,32 @@ fn validation(s: &mut Session, p: &Json) -> Result { "time" => ValidationKind::Time, "textLength" => ValidationKind::TextLength, "custom" => ValidationKind::Custom, - _ => ValidationKind::Any, + "any" => ValidationKind::Any, + // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. + other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), + }; + const OPERATORS: [&str; 12] = [ + "between", + "notBetween", + "equal", + "notEqual", + "greater", + "greaterThan", + "less", + "lessThan", + "greaterOrEqual", + "greaterThanOrEqual", + "lessOrEqual", + "lessThanOrEqual", + ]; + if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { + return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); + } + let error_style = match str_param(p, "errorStyle") { + None | Some("stop") => ErrorStyle::Stop, + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), }; let f1 = str_param(p, "formula1").unwrap_or(""); let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; @@ -944,11 +969,7 @@ fn validation(s: &mut Session, p: &Json) -> Result { show_input: true, error_title: str_param(p, "errorTitle").unwrap_or("").into(), error_message: str_param(p, "errorMessage").unwrap_or("").into(), - error_style: match str_param(p, "errorStyle") { - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - _ => ErrorStyle::Stop, - }, + error_style, show_error: true, }; edit(s, |cx| { @@ -1463,4 +1484,32 @@ mod tests { check(&reopened); assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); } + + #[test] + fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { + // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, + // with no error. + let mut s = Session::new(); + s.new_workbook(); + for params in [ + json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), + json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), + json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), + json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), + ] { + let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); + assert!(e.contains("unknown"), "{params}: {e}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + for params in [ + json!({"range": "A1", "type": "any"}), + json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), + json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), + ] { + s.execute("data.validation", params).unwrap(); + } + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.validations.len(), 3); + assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); + } } From 2a580f2902cd2d36c3c7c9b4acb10561a6e1b1ba Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:34:14 -0400 Subject: [PATCH 017/446] Conditional formats: a style fill of "none" means no fill `style: {fill: "none"}` (or "auto") went through the colour pass and came out as a solid fill in the automatic colour. `home.fillColor` reads the same word as no fill, so the rule style does too now. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 9 ++++++--- crates/engine/src/tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 0d58b0da5..985bb2a8a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1274,8 +1274,8 @@ fn is_string_literal(v: &str) -> bool { /// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`, /// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be /// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a -/// colour alone (a solid fill). An unknown key is an error rather than dropped: dropped, it left a -/// rule with an empty format that matches and shows nothing. +/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than +/// dropped: dropped, it left a rule with an empty format that matches and shows nothing. fn cf_style_json(st: &Json) -> Result { const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"]; let err = |m: String| bad("home.conditionalFormat", m); @@ -1299,7 +1299,10 @@ fn cf_style_json(st: &Json) -> Result { color(c, "font.color")?; } if let Some(fill) = st.get_mut("fill") { - if fill.is_string() || fill.get("theme").is_some() { + // `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one. + if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) { + *fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?; + } else if fill.is_string() || fill.get("theme").is_some() { *fill = json!({"pattern": "Solid", "fg": fill.clone()}); } if let Some(f) = fill.as_object_mut() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7b3fff840..1af74e67e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2643,6 +2643,14 @@ fn conditional_format_styles_take_hex_colours_and_quoted_values() { s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": internal}})).unwrap(); let st = style(&rule(&s, 0)); assert_eq!((st.fill.fg, st.borders.left.color), (Color::Rgb(0xFFF2CC), Color::Rgb(0xC00000))); + // `"none"` is no fill (the format only sets the font), as `home.fillColor` reads it. + s.execute( + "home.conditionalFormat", + json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": {"fill": "none", "font": {"bold": true}}}}), + ) + .unwrap(); + let st = style(&rule(&s, 0)); + assert_eq!((st.fill, st.font.bold), (Fill::default(), true)); // Keys that would be dropped, leaving a rule that shows nothing, are refused. let n = s.doc().unwrap().wb.active().unwrap().cond_formats.len(); for bad in [json!({"preset": "greenFill"}), json!({"fillColor": "#FFF2CC"}), json!({"fill": "#nothex"}), json!("bold")] { From 7b19b74a5964f428083f1d3c13281407d523a8f6 Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:37:45 -0400 Subject: [PATCH 018/446] Size commands: a null `sheet` is no `sheet` `{"sheet": null, "cols": "Data!B:B"}` was refused as naming a sheet other than `sheet`, while `target_sheet` and the cell parameters read a null `sheet` as absent. The prefix now decides, as it does when `sheet` is left out. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/tests.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 0c0c546aa..ee178c0d3 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -938,7 +938,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u let (prefix, body) = split_sheet(t); if let Some(name) = prefix { let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?; - if p.get("sheet").is_some() && named != sheet { + if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet { return Err(bad(key, format!("`{t}` names a sheet other than `sheet`"))); } sheet = named; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5db59594d..339d11b2a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2649,12 +2649,14 @@ fn line_sizes_and_freeze_panes_take_a_sheet() { s.execute("view.freezePanes", json!({"sheet": "Data", "cell": "A2"})).unwrap(); s.execute("home.columnWidth", json!({"sheet": "Data", "cols": "A:A", "width": 150})).unwrap(); s.execute("home.columnWidth", json!({"cols": "Data!B:B,D:D", "chars": 20})).unwrap(); + // A null `sheet` is no `sheet`, as everywhere else: the prefix decides. + s.execute("home.columnWidth", json!({"sheet": null, "cols": "Data!C:C", "chars": 20})).unwrap(); s.execute("home.rowHeight", json!({"sheet": 1, "rows": "1:1", "height": 40})).unwrap(); s.execute("home.hideColumns", json!({"sheet": "Data", "cols": "E:E"})).unwrap(); s.execute("home.defaultWidth", json!({"sheet": "Data", "width": 80})).unwrap(); let data = sheet(&s, 1); assert_eq!(data.freeze, Some((1, 0))); - assert_eq!((data.col_width(0), data.col_width(1), data.col_width(3)), (150.0, 145.0, 145.0)); + assert_eq!((data.col_width(0), data.col_width(1), data.col_width(2), data.col_width(3)), (150.0, 145.0, 145.0, 145.0)); assert_eq!(data.row_height(0), 40.0); assert!(data.cols.get(&4).is_some_and(|c| c.hidden)); assert_eq!(data.default_col_width, 80.0); From d0d4d26c74c3d74626135b77742fac930ab7b5c7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:12:34 +0200 Subject: [PATCH 019/446] =?UTF-8?q?UI:=20File=20=E2=80=BA=20Account=20show?= =?UTF-8?q?s=20the=20version,=20platform=20and=20licence,=20with=20an=20Ab?= =?UTF-8?q?out=20GridCraft=20button=20(fixes=20#896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/backstage.rs | 63 ++++++++++++++++++++++++++++--- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/i18n.rs | 7 ++++ crates/ui-egui/src/i18n/cs.rs | 6 +++ crates/ui-egui/src/i18n/de.rs | 7 ++++ crates/ui-egui/src/i18n/es.rs | 7 ++++ crates/ui-egui/src/i18n/fr.rs | 7 ++++ crates/ui-egui/src/i18n/ko.rs | 7 ++++ crates/ui-egui/src/i18n/pt.rs | 5 +++ crates/ui-egui/src/i18n/ru.rs | 7 ++++ crates/ui-egui/src/i18n/sr.rs | 7 ++++ crates/ui-egui/src/i18n/zh.rs | 7 ++++ crates/ui-egui/tests/file_menu.rs | 16 +++++++- docs/control-protocol.md | 2 +- 14 files changed, 142 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ae2155419..c57537a32 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -2,7 +2,7 @@ //! and sheet tabs until Esc or the back arrow returns to the sheet. //! //! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, -//! Options); Save and Close act at once. Every action runs an existing command through +//! Options, Account); Save and Close act at once. Every action runs an existing command through //! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is //! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched //! until the user picks one. @@ -31,10 +31,11 @@ pub enum Page { Print, Export, Options, + Account, } impl Page { - pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options]; + pub const ALL: [Page; 9] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options, Page::Account]; /// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take. pub fn id(self) -> &'static str { @@ -47,6 +48,7 @@ impl Page { Page::Print => "print", Page::Export => "export", Page::Options => "options", + Page::Account => "account", } } @@ -66,6 +68,7 @@ impl Page { Page::Print => "Print", Page::Export => "Export", Page::Options => "Options", + Page::Account => "Account", } } @@ -79,6 +82,7 @@ impl Page { Page::Print => Icon::Print, Page::Export => Icon::Share, Page::Options => Icon::Settings, + Page::Account => Icon::Info, } } } @@ -174,9 +178,12 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { out.close = true; } } + // Bottom up: Account sits just above Options, as in Excel. ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { - if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() { - out.page = Some(Page::Options); + for page in [Page::Options, Page::Account] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } } }); }); @@ -195,6 +202,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { Page::Print => print(app, ui, &mut bs, &mut out), Page::Export => export(app, ui, &mut out), Page::Options => options(app, ui), + Page::Account => account(app, ui), } }); }); @@ -852,6 +860,51 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { dim(ui, lang.tr("Calculation settings are saved with the workbook.")); } +/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web". +fn platform() -> String { + let os = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + "netbsd" => "NetBSD", + _ if cfg!(target_arch = "wasm32") => return "Web".into(), + other => other, + }; + format!("{os} ({})", std::env::consts::ARCH) +} + +/// Excel's File › Account: product information and the About window. +fn account(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + section(ui, lang, "Product Information"); + ui.label(RichText::new("GridCraft").font(theme::ui_bold(16.0)).color(t.text)); + ui.add_space(8.0); + egui::Grid::new("backstage_account").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { + for (label, value) in + [("Version", env!("CARGO_PKG_VERSION").to_string()), ("Platform", platform()), ("Licence", env!("CARGO_PKG_LICENSE").to_string())] + { + ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); + ui.label(value); + ui.end_row(); + } + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + if square_button(ui, Icon::Info, lang.tr("About GridCraft")).clicked() { + app.open_dialog("about", json!({})); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(14.0); + ui.label(RichText::new(lang.tr("About GridCraft")).font(theme::ui_bold(13.5)).color(t.text)); + dim(ui, lang.tr("Version, contributors and the AI models that helped")); + }); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -862,7 +915,7 @@ mod tests { assert_eq!(Page::parse(page.id()), Some(page)); } assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs)); - assert_eq!(Page::parse("account"), None); + assert_eq!(Page::parse("feedback"), None); assert_eq!(Page::parse(""), None); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d27ea052e..54dcadaeb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -183,7 +183,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), - ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), + ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), ( "ui.printSettings", "Print Settings", diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4cc6a3e37..f8c318bc2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -519,6 +519,13 @@ const JAPANESE: &[(&str, &str)] = &[ ("Export", "エクスポート"), ("Close", "閉じる"), ("Options", "オプション"), + ("Account", "アカウント"), + ("Product Information", "製品情報"), + ("About GridCraft", "GridCraft について"), + ("Version", "バージョン"), + ("Platform", "プラットフォーム"), + ("Licence", "ライセンス"), + ("Version, contributors and the AI models that helped", "バージョン、貢献者、支援した AI モデル"), ("Blank workbook", "空白のブック"), ("Recent", "最近使ったアイテム"), ("No recent workbooks yet.", "最近使ったブックはまだありません。"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index bf8c35f9b..81848f599 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -258,6 +258,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportovat", "Close" => "Zavřít", "Options" => "Možnosti", + "Account" => "Účet", + "Product Information" => "Informace o produktu", + "About GridCraft" => "O aplikaci GridCraft", + "Version" => "Verze", + "Platform" => "Platforma", + "Licence" => "Licence", "Blank workbook" => "Prázdný sešit", "Recent" => "Naposledy použité", "No recent workbooks yet." => "Zatím žádné naposledy použité sešity.", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 75653e583..246c63572 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -256,6 +256,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportieren", "Close" => "Schließen", "Options" => "Optionen", + "Account" => "Konto", + "Product Information" => "Produktinformationen", + "About GridCraft" => "Info zu GridCraft", + "Version" => "Version", + "Platform" => "Plattform", + "Licence" => "Lizenz", + "Version, contributors and the AI models that helped" => "Version, Mitwirkende und die KI-Modelle, die geholfen haben", "Blank workbook" => "Leere Arbeitsmappe", "Recent" => "Zuletzt verwendet", "No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 0582110c7..948dc7dd1 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -257,6 +257,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportar", "Close" => "Cerrar", "Options" => "Opciones", + "Account" => "Cuenta", + "Product Information" => "Información del producto", + "About GridCraft" => "Acerca de GridCraft", + "Version" => "Versión", + "Platform" => "Plataforma", + "Licence" => "Licencia", + "Version, contributors and the AI models that helped" => "Versión, colaboradores y los modelos de IA que ayudaron", "Blank workbook" => "Libro en blanco", "Recent" => "Recientes", "No recent workbooks yet." => "Todavía no hay libros recientes.", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index c70b25cf5..780065501 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -342,6 +342,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exporter", "Close" => "Fermer", "Options" => "Options", + "Account" => "Compte", + "Product Information" => "Informations sur le produit", + "About GridCraft" => "À propos de GridCraft", + "Version" => "Version", + "Platform" => "Plateforme", + "Licence" => "Licence", + "Version, contributors and the AI models that helped" => "Version, contributeurs et les modèles d'IA qui ont aidé", "Blank workbook" => "Classeur vide", "Recent" => "Récent", "No recent workbooks yet." => "Aucun classeur récent pour le moment.", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index f3e1688d0..9849d451c 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -344,6 +344,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "내보내기", "Close" => "닫기", "Options" => "옵션", + "Account" => "계정", + "Product Information" => "제품 정보", + "About GridCraft" => "GridCraft 정보", + "Version" => "버전", + "Platform" => "플랫폼", + "Licence" => "라이선스", + "Version, contributors and the AI models that helped" => "버전, 기여자 및 도움을 준 AI 모델", "Blank workbook" => "새 통합 문서", "Recent" => "최근 항목", "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 9ab5b7baf..27f504eee 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -345,6 +345,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportar", "Close" => "Fechar", "Options" => "Opções", + "Account" => "Conta", + "Product Information" => "Informações do produto", + "Version" => "Versão", + "Platform" => "Plataforma", + "Licence" => "Licença", "Blank workbook" => "Pasta de trabalho em branco", "Recent" => "Recentes", "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 868f653b8..2bfa63bb4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -344,6 +344,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Экспорт", "Close" => "Закрыть", "Options" => "Параметры", + "Account" => "Учетная запись", + "Product Information" => "Сведения о продукте", + "About GridCraft" => "О программе GridCraft", + "Version" => "Версия", + "Platform" => "Платформа", + "Licence" => "Лицензия", + "Version, contributors and the AI models that helped" => "Версия, участники и модели ИИ, которые помогали", "Blank workbook" => "Пустая книга", "Recent" => "Последние", "No recent workbooks yet." => "Недавних книг пока нет.", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 152bddee8..9621d7d3a 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -336,6 +336,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Izvezi", "Close" => "Zatvori", "Options" => "Opcije", + "Account" => "Nalog", + "Product Information" => "Informacije o proizvodu", + "About GridCraft" => "O programu GridCraft", + "Version" => "Verzija", + "Platform" => "Platforma", + "Licence" => "Licenca", + "Version, contributors and the AI models that helped" => "Verzija, saradnici i AI modeli koji su pomogli", "Blank workbook" => "Prazna radna sveska", "Recent" => "Nedavno", "No recent workbooks yet." => "Još nema nedavnih radnih svezaka.", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index a9989f2e4..1af3d4563 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -342,6 +342,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "导出", "Close" => "关闭", "Options" => "选项", + "Account" => "账户", + "Product Information" => "产品信息", + "About GridCraft" => "关于 GridCraft", + "Version" => "版本", + "Platform" => "平台", + "Licence" => "许可证", + "Version, contributors and the AI models that helped" => "版本、贡献者以及参与协助的 AI 模型", "Blank workbook" => "空白工作簿", "Recent" => "最近", "No recent workbooks yet." => "还没有最近使用的工作簿。", diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 691eef367..abdc11592 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -122,7 +122,7 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { h.run_steps(2); assert_eq!(page(&h), Some(id)); } - assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err()); + assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).is_err()); h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); assert!(h.state().backstage.is_none()); // The back arrow returns to the sheet too. @@ -132,6 +132,20 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { assert!(h.state().backstage.is_none()); } +#[test] +fn file_account_shows_the_version_and_opens_about() { + let mut h = harness(Services::default()); + choose(&mut h, "Account"); + assert_eq!(page(&h), Some("account")); + assert!(h.state().dialog.is_none()); + // The About dialog also shows the version; it must not be open yet. + h.get_by_label(env!("CARGO_PKG_VERSION")); + h.get_by_label(env!("CARGO_PKG_LICENSE")); + click(&mut h, "About GridCraft"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("about")); + assert_eq!(page(&h), Some("account"), "About opens over the File view, like Protect Workbook on Info"); +} + #[test] fn export_as_pdf_is_delivered_as_a_download() { let files = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..bd1c43262 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,7 +32,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | -| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | +| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | From 06c8ff640da1676ba504ec78db1e5b6f2bc1fc40 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:31:28 +0200 Subject: [PATCH 020/446] Keytips: Alt, F opens the File view, and its F letters still run (part of #893) --- .../src/keytips/keytip_table/generated.rs | 1 + crates/ui-egui/src/keytips/keytips.json | 7 +++++++ crates/ui-egui/src/lib.rs | 9 ++++++++- crates/ui-egui/src/ribbon.rs | 5 +++++ crates/ui-egui/tests/keytips.rs | 19 +++++++++++++++++++ crates/ui-egui/tests/keytips_generated.rs | 10 +++++++++- xtask/src/keytips.rs | 7 ++++++- 7 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..cde8066a4 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -6,6 +6,7 @@ use super::{Action, Entry}; /// whenever a pressed letter starts a deeper sequence, so only terminal entries are /// listed here. See keytip_table.rs for the helpers that read this table. pub const SEQUENCES: &[(&str, Entry)] = &[ + ("F", Entry::Tab("File")), // Excel: File tab (Backstage view); Opens the File view on Home; its letters below still run their commands. ("H", Entry::Tab("Home")), ("N", Entry::Tab("Insert")), ("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors. diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..729cf673b 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -11,6 +11,13 @@ " - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)." ], "sequences": [ + { + "seq": "F", + "kind": "tab", + "target": "File", + "excel": "File tab (Backstage view)", + "note": "Opens the File view on Home; its letters below still run their commands." + }, { "seq": "H", "kind": "tab", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be0319972..68e896fc5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1095,7 +1095,8 @@ impl SheetApp { // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); - if self.backstage.is_none() { + // Keytips opened the File view (Alt, F): its letters keep working there. + if self.backstage.is_none() || self.keytips.prefix().is_some() { self.ribbon_keys(&ctx); } ribbon::title_bar(self, ui); @@ -1172,12 +1173,18 @@ impl SheetApp { && self.grid.renaming_tab.is_none() && (before.is_some() || !egui::Popup::is_any_open(ctx)); let actions = self.keytips.process(ctx, enabled); + // Backing out of F to the tab letters, or finishing an F sequence, closes the File view + // that Alt, F opened: the command runs on the sheet, as it did without the view. + if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { + self.backstage = None; + } if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { // Leave pointer cancellation to the popup so clicking a menu item still works. egui::Popup::close_all(ctx); } for action in actions { match action { + keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), keytips::Action::Tab(tab) => { self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); self.ui.ribbon_collapsed = false; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 576e0040d..1445facaa 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -263,6 +263,11 @@ fn file_menu(app: &mut SheetApp, ui: &mut Ui) { if file.clicked() { app.open_backstage(crate::backstage::Page::Home); } + if app.keytips.prefix() == Some("") + && let Some(sequence) = keytip_table::tab_sequence("File") + { + keytip(app, ui, sequence, pos2(file.rect.center().x, file.rect.bottom() - 1.0)); + } } /// System / Light / Dark, as in the View tab's Display theme box (`view.theme`). diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..2d8ef4267 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -316,6 +316,25 @@ fn tab_letters_switch_to_their_ribbon_tabs() { assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly"); } +#[test] +fn f_opens_the_file_view_and_its_letters_still_run() { + use gridcraft_ui_egui::backstage::Page; + let mut h = harness(blank()); + sequence(&mut h, &[Key::F], false); + assert_eq!(h.state().backstage.as_ref().map(|b| b.page), Some(Page::Home), "F opens the File view on Home"); + assert_eq!(h.state().keytips.prefix(), Some("F")); + // Esc backs out one level: the File view closes and the tab letters show again. + key(&mut h, Key::Escape); + assert!(h.state().backstage.is_none()); + assert_eq!(h.state().keytips.prefix(), Some("")); + letter(&mut h, Key::F, false); + assert!(h.state().backstage.is_some()); + letter(&mut h, Key::N, false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.new"), "F, N still runs New"); + assert!(h.state().backstage.is_none(), "the command runs on the sheet, as before the view opened"); + assert_eq!(h.state().keytips.prefix(), None); +} + #[test] fn view_gridlines_sequence_runs_end_to_end() { let mut h = harness(blank()); diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..cece3b3ed 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -59,6 +59,14 @@ fn sequence(h: &mut Harness, seq: &str, text_first: bool) { } } +#[test] +fn keytip_f_() { + let mut h = harness(blank()); + sequence(&mut h, "F", false); + assert!(h.state().backstage.is_some(), "F must open the File view"); + assert_eq!(h.state().keytips.prefix(), Some("F")); +} + #[test] fn keytip_h_() { let mut h = harness(blank()); @@ -2741,7 +2749,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 364; + const EXPECTED: usize = 365; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 36847222f..54c332c11 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -10,7 +10,8 @@ use std::fs; use std::path::Path; -const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; +/// "File" is not a ribbon tab: its sequence opens the Backstage view. +const TABS: &[&str] = &["File", "Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; /// Commands whose empty-params behaviour depends on the host machine (e.g. the /// system dictionary). These are never probed; their tests assert "invoked @@ -260,6 +261,10 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { let mut out = String::from(HEADER); for e in entries { let body = match e.kind.as_str() { + "tab" if e.target == "File" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the File view\");\n assert_eq!(h.state().keytips.prefix(), Some({seq:?}));", + seq = e.seq, + ), "tab" => format!( "sequence(&mut h, {seq:?}, false);\n assert_eq!(h.state().ui.ribbon_tab, {tab:?}, \"{seq} must switch to the {tab} tab\");", seq = e.seq, From a54cf58f2e060223d81261f3edc9f7a79d0bd7a1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:48:23 +0200 Subject: [PATCH 021/446] Keyboard: Enter after a run of Tabs goes to the next row in the column the run started from (fixes #911) --- crates/ui-egui/src/lib.rs | 19 +++++++++++++++++++ crates/ui-egui/tests/interaction.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be0319972..189fe106b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -258,6 +258,9 @@ pub struct SheetApp { /// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen /// holding other text. Once the cut has been pasted, Paste leaves this text alone. cut_text: Option, + /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). + /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). + tab_run: Option<(u64, usize, u32, CellRef)>, pub started: f64, } @@ -301,6 +304,7 @@ impl SheetApp { selected_chart: None, clipboard_text_at_copy: None, cut_text: None, + tab_run: None, started: now_ms(), } } @@ -951,11 +955,26 @@ impl SheetApp { /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. pub fn move_after_enter(&mut self, dr: i64, dc: i64) { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); + // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the + // last Tab landed on, which ends the run. + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active)); + let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); if multi { let fwd = dr > 0 || dc > 0; let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); + } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { + let back = i64::from(col) - i64::from(at.col); + let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); } else { let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); + if dr == 0 && dc != 0 { + let start = run.map(|r| r.2); + self.tab_run = self.session.active().and_then(|d| { + let (uid, sheet) = (d.uid, d.wb.active_sheet); + let col = start.or(here.map(|h| h.2.col))?; + Some((uid, sheet, col, d.selection.active)) + }); + } } self.grid.ensure_visible = true; } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 0958c8c98..04e08d8b3 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -177,7 +177,8 @@ fn keyboard_navigation_does_not_replay_the_last_pointer_click() { (Key::Enter, Modifiers::NONE, "B5"), (Key::Enter, Modifiers::SHIFT, "B4"), (Key::Tab, Modifiers::NONE, "C4"), - (Key::Enter, Modifiers::NONE, "C5"), + // #911: Enter after a Tab goes back to the column the Tab started from. + (Key::Enter, Modifiers::NONE, "B5"), ] { key(&mut h, key_code, modifiers); let selection = &h.state().session.active().unwrap().selection; @@ -241,6 +242,32 @@ fn typing_enters_values_and_moves_down() { assert_eq!(active.as_deref(), Some("B2")); } +#[test] +fn enter_after_tabs_returns_to_the_column_where_the_tabs_started() { + // #911: Excel moves to the next row in the column the run of Tabs started from. + let mut h = harness(blank()); + for t in ["a", "b"] { + text(&mut h, t); + key(&mut h, Key::Tab, Modifiers::NONE); + } + text(&mut h, "c"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "C1"), Value::from("c")); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + // The next Enter is an ordinary one again. + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A3"); +} + +#[test] +fn moving_with_an_arrow_ends_the_run_of_tabs() { + let mut h = harness(blank()); + key(&mut h, Key::Tab, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C2"); +} + #[test] fn arrows_escape_and_undo() { let mut h = harness(blank()); From f644004cc66392a1c94854aa1d587758c95aa628 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 10:35:14 +0200 Subject: [PATCH 022/446] Sheet tabs: New sheet (+) right after the last tab, the strip capped at 60% (part of #943) --- crates/ui-egui/src/tabs.rs | 5 ++-- crates/ui-egui/tests/sheet_tabs.rs | 46 +++++++++++++++++++++++++++++- docs/ui-parity.md | 5 ++-- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..a2b2ac818 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -69,7 +69,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) .collect(); // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the - // tabs get 60% of the bar and the grid's horizontal scrollbar the rest. + // tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip + // that needs less shrinks to its tabs, so New sheet (+) sits right after the last one. let tabs_width = (ui.available_width() * 0.6).max(100.0); // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. @@ -94,7 +95,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { egui::ScrollArea::horizontal() .id_salt("worksheet_tabs_horizontal") - .auto_shrink([false, false]) + .auto_shrink([true, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .show(ui, |ui| { if wheel_scroll.abs() > 0.0 { diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index 7b43cd12b..b36adf3dd 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -7,10 +7,14 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(width: f32, active: usize) -> Harness { + harness_with(width, 30, active) +} + +fn harness_with(width: f32, sheets: usize, active: usize) -> Harness { let mut s = Session::new(); s.new_workbook(); s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); - for i in 1..30 { + for i in 1..sheets { s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); } s.execute("sheet.activate", json!({"sheet":active})).unwrap(); @@ -162,3 +166,43 @@ fn overflow_leaves_the_worksheet_scrollbar_reachable() { assert!(track.left() >= 0.0 && track.right() <= width + 0.5, "{width}: the scrollbar is offscreen: {track:?}"); } } + +/// The New sheet button's left edge. The scrollbar track starts 8 px after the 26 px button. +fn new_sheet_left(h: &Harness) -> f32 { + h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar").left() - 8.0 - 26.0 +} + +/// The painted background of the active tab: the smallest fill enclosing its name (the bar's own fill encloses it too). +fn active_tab_rect(h: &Harness, name: &str) -> Rect { + let (text, _) = tab(h, name).unwrap(); + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Rect(r) if r.rect.contains_rect(text) => Some(r.rect), + _ => None, + }) + .min_by(|a, b| a.area().total_cmp(&b.area())) + .unwrap_or_else(|| panic!("{name} has no painted tab background")) +} + +#[test] +fn new_sheet_button_follows_the_last_tab() { + for width in [700.0, 1200.0] { + let h = harness_with(width, 2, 1); + let last = active_tab_rect(&h, "Tab01"); + let plus = new_sheet_left(&h); + assert!(plus >= last.right() && plus - last.right() <= 12.0, "{width}: + at {plus}, last tab ends at {}", last.right()); + } +} + +#[test] +fn overflowing_tabs_keep_their_share_and_the_new_sheet_button_after_it() { + for width in [700.0, 1200.0] { + let h = harness(width, 0); + let (_, viewport) = tab(&h, "Tab00").unwrap(); + assert!(viewport.width() <= width * 0.6, "{width}: the tab strip grew past its share: {viewport:?}"); + let plus = new_sheet_left(&h); + assert!(plus >= viewport.right() && plus - viewport.right() <= 12.0, "{width}: + at {plus}, strip ends at {}", viewport.right()); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..3d17238c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (New sheet (+) right after the last sheet tab, #943) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Sheet tab bar: the tab strip shrinks to its tabs, up to 60% of the bar, so New sheet (+) sits right after the last tab and the horizontal scrollbar takes the rest (part of #943) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | From 4ec6a373b015d1456855b97800cbf4d7823f3959 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 10:35:59 +0200 Subject: [PATCH 023/446] Ribbon: Font Size is a box you type into, the sizes behind its arrow (fixes #932) --- crates/ui-egui/src/ribbon.rs | 109 ++++++++++++++-------- crates/ui-egui/tests/font_size_box.rs | 125 ++++++++++++++++++++++++++ crates/ui-egui/tests/keytips.rs | 5 +- 3 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 crates/ui-egui/tests/font_size_box.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..99b3b01e2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -380,8 +380,6 @@ pub(crate) enum ComboResult { None, /// A row was applied: clicked or Enter on the highlighted row. Picked(String), - /// Enter with free text that matches no row (e.g. a custom font size). - Entered(String), } /// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. @@ -394,6 +392,24 @@ pub(crate) fn editable_combo( current_value: &str, items: &[(&str, String)], forced: bool, +) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, forced, true) +} + +/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or +/// arrows and Enter. +fn list_combo(ui: &mut Ui, anchor: &egui::Response, salt: &'static str, current_value: &str, items: &[(&str, String)]) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, false, false) +} + +fn combo_popup( + ui: &mut Ui, + anchor: &egui::Response, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], + forced: bool, + filter: bool, ) -> ComboResult { let popup_id = anchor.id.with("popup"); let state_id = anchor.id.with(salt).with("__combo_state"); @@ -424,15 +440,17 @@ pub(crate) fn editable_combo( }); }); } - let text_id = anchor.id.with(salt).with("__combo_text"); - let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); - if open_now && !te.has_focus() { - ui.ctx().memory_mut(|m| m.request_focus(text_id)); - // Select all once focus lands so typing replaces the filter. - if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { - let len = st.text.chars().count(); - s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); - s.store(ui.ctx(), text_id); + if filter { + let text_id = anchor.id.with(salt).with("__combo_text"); + let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); + if open_now && !te.has_focus() { + ui.ctx().memory_mut(|m| m.request_focus(text_id)); + // Select all once focus lands so typing replaces the filter. + if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { + let len = st.text.chars().count(); + s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + s.store(ui.ctx(), text_id); + } } } let lower = st.text.to_lowercase(); @@ -446,18 +464,13 @@ pub(crate) fn editable_combo( st.sel = (st.sel - 1).rem_euclid(n); } } - if enter { - if filtered.is_empty() { - let t = st.text.trim().to_string(); - if !t.is_empty() { - result = ComboResult::Entered(t); - } - } else { - let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize]; - result = ComboResult::Picked(items[idx].0.to_string()); - } + if enter && !filtered.is_empty() { + let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize]; + result = ComboResult::Picked(items[idx].0.to_string()); + } + if filter { + ui.separator(); } - ui.separator(); egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { for i in &filtered { let (value, disp) = &items[*i]; @@ -1092,25 +1105,51 @@ pub const FONTS: &[&str] = &[ "Verdana", ]; +/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses +/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes. fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { + let t = Tokens::get(ui.ctx()); let size = st.font.size; - let label = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; - let resp = ui.add_sized([52.0, 24.0], egui::Button::new(egui::RichText::new(&label).font(theme::ui_font(12.5)))); + let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; + let field = egui::Id::new("gridcraft.font_size_box"); + let draft = field.with("draft"); + let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); + let gap = std::mem::replace(&mut ui.spacing_mut().item_spacing.x, 0.0); + let out = egui::TextEdit::singleline(&mut text).id(field).font(theme::ui_font(12.5)).desired_width(30.0).show(ui); + let resp = out.response; control_keytip(app, ui, &resp, "home.fontSize"); - let forced = invoke(app, "home.fontSize"); + if invoke(app, "home.fontSize") { + ui.memory_mut(|m| m.request_focus(field)); + } + if resp.gained_focus() { + // Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip. + let mut state = out.state; + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), field); + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove::(draft)); + // Consume the Enter so the grid behind does not also move the active cell down. + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) + && let Some(v) = text.trim().parse::().ok().filter(|v| v.is_finite()) + { + act(app, "home.fontSize", json!({"size": v})); + } + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, text)); + } else { + ui.data_mut(|d| d.remove::(draft)); + } + let arrow = icon_button(ui, Icon::Chevron, t.text_dim, "Font Size", vec2(14.0, 24.0)); + ui.spacing_mut().item_spacing.x = gap; + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Font Size")); let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); - let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; - let mut apply = |s: &str| { - if let Ok(v) = s.parse::() { - act(app, "home.fontSize", json!({"size": v})); - } - }; - match editable_combo(ui, &resp, "size", ¤t, &items, forced) { - ComboResult::Picked(value) => apply(&value), - ComboResult::Entered(text) => apply(&text), - _ => {} + if let ComboResult::Picked(value) = list_combo(ui, &arrow, "size", ¤t, &items) + && let Ok(v) = value.parse::() + { + act(app, "home.fontSize", json!({"size": v})); } } diff --git a/crates/ui-egui/tests/font_size_box.rs b/crates/ui-egui/tests/font_size_box.rs new file mode 100644 index 000000000..0337543ad --- /dev/null +++ b/crates/ui-egui/tests/font_size_box.rs @@ -0,0 +1,125 @@ +//! Home ▸ Font Size is a box you type a size into, with the standard sizes behind its arrow (#932). + +use egui::{Event, Key, Modifiers, accesskit::Role}; +use egui_kittest::kittest::Queryable as _; +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>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, key: Key) { + h.input_mut().events.extend([ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +/// Alt, H, F, S focuses the size box, then `text` is typed and confirmed with Enter. +fn type_size(h: &mut Harness, text: &str) { + key(h, Key::F10); + for k in [Key::H, Key::F, Key::S] { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(k.name().to_ascii_lowercase())); + h.run_steps(2); + } + h.run_steps(3); + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(1); + } + key(h, Key::Enter); + h.run_steps(3); +} + +fn size(h: &Harness, cell: &str) -> f32 { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.size +} + +#[test] +fn typed_size_is_applied_exactly_to_the_selection() { + for (typed, want) in [("6", 6.0), ("2", 2.0), ("10.5", 10.5)] { + let mut h = harness(); + type_size(&mut h, typed); + assert!(h.state().message.is_none(), "typing {typed} must not raise an error"); + for cell in ["A1", "B2"] { + assert_eq!(size(&h, cell), want, "typing {typed} + Enter sets {cell} to {want}"); + } + assert_eq!( + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()), + Value::Empty, + "the digits go to the box, not the cell" + ); + } +} + +#[test] +fn out_of_range_or_not_a_number_changes_nothing() { + let mut h = harness(); + type_size(&mut h, "500"); + let (_, text) = h.state().message.clone().expect("500 is refused with a message"); + assert!(text.contains("between 1 and 409"), "{text}"); + assert_eq!(size(&h, "A1"), 11.0); + + let mut h = harness(); + type_size(&mut h, "abc"); + assert!(h.state().message.is_none()); + assert_eq!(size(&h, "A1"), 11.0, "text that is not a number reverts"); +} + +#[test] +fn escape_cancels_the_typed_size() { + let mut h = harness(); + key(&mut h, Key::F10); + for k in [Key::H, Key::F, Key::S] { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(k.name().to_ascii_lowercase())); + h.run_steps(2); + } + h.run_steps(3); + h.input_mut().events.push(Event::Text("6".into())); + h.run_steps(1); + key(&mut h, Key::Escape); + h.run_steps(3); + assert_eq!(size(&h, "A1"), 11.0); + assert!(h.get_all_by_role(Role::TextInput).any(|n| n.value().as_deref() == Some("11")), "the box shows the cell's size again"); +} + +#[test] +fn the_arrow_opens_the_plain_list_of_sizes() { + let mut h = harness(); + h.get_by_label("Font Size").click(); + h.run_steps(3); + assert!(h.query_by_label("Type to filter…").is_none(), "the size list has no filter field"); + // The grid's row headers carry the same numbers as labels: pick the list's button. + h.get_by_role_and_label(Role::Button, "16").click(); + h.run_steps(3); + assert_eq!(size(&h, "B2"), 16.0, "clicking a size applies it"); + + let mut h = harness(); + h.get_by_label("Font Size").click(); + h.run_steps(3); + key(&mut h, Key::ArrowDown); + key(&mut h, Key::Enter); + h.run_steps(3); + assert_eq!(size(&h, "A1"), 12.0, "Down from 11 then Enter picks 12"); +} diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..1d96b32df 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -495,7 +495,10 @@ fn editable_combos_filter_by_typing_and_move_by_arrows() { key(&mut h, Key::Enter); h.run_steps(3); assert!(h.state().message.is_none() && h.state().toast.is_none(), "custom sizes must apply"); - assert_eq!(h.query_all_by_label("13.5").count(), 1, "the button must show the custom size"); + assert!( + h.get_all_by_role(egui::accesskit::Role::TextInput).any(|n| n.value().as_deref() == Some("13.5")), + "the size box must show the custom size" + ); // Closing without applying (Escape) and reopening must reset the filter to the full list. let mut h = harness(blank()); From 5a7fbfa5b8f4a2f697d82de8df966a36e13c6f96 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 10:46:07 +0200 Subject: [PATCH 024/446] Contributors: add my real name --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index d522b84e0..b92d6bb77 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +sync_github_name = false From 8f9338a5ec19188c8a4732682e23858244358077 Mon Sep 17 00:00:00 2001 From: Anirudh Sharma Date: Tue, 13 Oct 2026 14:21:00 +0530 Subject: [PATCH 025/446] fix(fill): respect workbook date system in monthly autofill --- crates/engine/src/fill.rs | 7 ++++--- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..3cd4570aa 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -165,6 +165,7 @@ enum Pattern { last: f64, first: f64, months: i64, + sys: gridcraft_core::DateSystem, }, } @@ -199,7 +200,7 @@ impl Pattern { && (a.month != b.month || a.year != b.year) { let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64; - return Pattern::Months { last, first, months }; + return Pattern::Months { last, first, months, sys }; } } // Least-squares step for 2+ values (Excel uses a linear trend). @@ -297,9 +298,9 @@ impl Pattern { s })) } - Pattern::Months { last, first, months } => { + Pattern::Months { last, first, months, sys } => { let base = if forward { *last } else { *first }; - let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?; + let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?; Some(Value::number(s)) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..68ee3632c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2897,6 +2897,33 @@ fn fill_series_year_clamps_leap_day() { assert_eq!(v(&s, "A5"), ymd(2032, 2, 29)); } +#[test] +fn autofill_month_pattern_uses_workbook_date_system() { + use std::sync::Arc; + + use gridcraft_core::date::serial_from_ymd; + use gridcraft_core::{DateSystem, RangeRef}; + + for sys in [DateSystem::D1900, DateSystem::D1904] { + for (source, target, seeds, expected) in [ + ("A1:A2", "A1:A6", ["2026-01-31", "2026-03-31"], ["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31", "2026-09-30", "2026-11-30"]), + ("A5:A6", "A1:A6", ["2026-09-30", "2026-11-30"], ["2026-01-30", "2026-03-30", "2026-05-30", "2026-07-30", "2026-09-30", "2026-11-30"]), + ] { + let mut s = s(); + Arc::make_mut(&mut s.doc_mut().unwrap().wb).date_system = sys; + let start = RangeRef::parse(source).unwrap().start.a1(); + s.execute("range.setValues", json!({"range": start, "values": [[seeds[0]], [seeds[1]]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": source, "target": target})).unwrap(); + for (row, date) in expected.iter().enumerate() { + let parts: Vec = date.split('-').map(|p| p.parse().unwrap()).collect(); + let expected = Value::Number(serial_from_ymd(sys, parts[0], parts[1], parts[2]).unwrap()); + let cell = CellRef::new(row as u32, 0).a1(); + assert_eq!(v(&s, &cell), expected, "{sys:?} {source} -> {target}, {cell}: {date}"); + } + } + } +} + #[test] fn autofill_month_pattern_clamps_to_month_end() { let mut s = s(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..a1e74f50f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (AutoFill month patterns respect the workbook's date system) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -45,7 +45,7 @@ this logic, with one undo step. |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | AutoFill month patterns retain the workbook's date system, preventing shifted dates in 1904 workbooks when filling forward or backward | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | From 4bcc5c87d175b449931f56c4bccea5b32665418e Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Oct 2026 20:09:54 +1100 Subject: [PATCH 026/446] Ribbon: Font Size box matches the Font name box, list drops under it The size box is one frame with the Font name box's fill, border, height and corners, with the text field and the dropdown arrow inside it (focus shows the selection stroke). The size list drops directly below the box and is as wide as it, instead of opening 220 pt wide under the arrow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc --- crates/ui-egui/src/ribbon.rs | 46 +++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 99b3b01e2..d2d5fe101 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -393,13 +393,14 @@ pub(crate) fn editable_combo( items: &[(&str, String)], forced: bool, ) -> ComboResult { - combo_popup(ui, anchor, salt, current_value, items, forced, true) + combo_popup(ui, anchor, salt, current_value, items, forced, true, None) } /// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or -/// arrows and Enter. -fn list_combo(ui: &mut Ui, anchor: &egui::Response, salt: &'static str, current_value: &str, items: &[(&str, String)]) -> ComboResult { - combo_popup(ui, anchor, salt, current_value, items, false, false) +/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size +/// list under its box. +fn list_combo(ui: &mut Ui, anchor: &egui::Response, under: Rect, salt: &'static str, current_value: &str, items: &[(&str, String)]) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, false, false, Some(under)) } fn combo_popup( @@ -410,6 +411,7 @@ fn combo_popup( items: &[(&str, String)], forced: bool, filter: bool, + under: Option, ) -> ComboResult { let popup_id = anchor.id.with("popup"); let state_id = anchor.id.with(salt).with("__combo_state"); @@ -424,7 +426,12 @@ fn combo_popup( } st.was_open = open_now; let mut result = ComboResult::None; - egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { + let mut popup = egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())); + if let Some(r) = under { + popup = popup.anchor(r).align(egui::RectAlign::BOTTOM_START).width(r.width()); + } + let row_w = under.map_or(220.0, |r| r.width() - 12.0); + popup.show(|ui| { // Arrows and Enter navigate the list, not the filter field's cursor. Capture them and // strip them from the input so the TextEdit never sees them. let (mut down, mut up, mut enter) = (false, false, false); @@ -474,7 +481,7 @@ fn combo_popup( egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { for i in &filtered { let (value, disp) = &items[*i]; - let row = ui.add(egui::Button::selectable(*i as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(220.0, 20.0))); + let row = ui.add(egui::Button::selectable(*i as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0))); if row.clicked() { result = ComboResult::Picked(value.to_string()); } @@ -1114,8 +1121,21 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let field = egui::Id::new("gridcraft.font_size_box"); let draft = field.with("draft"); let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); - let gap = std::mem::replace(&mut ui.spacing_mut().item_spacing.x, 0.0); - let out = egui::TextEdit::singleline(&mut text).id(field).font(theme::ui_font(12.5)).desired_width(30.0).show(ui); + // One frame like the Font name box beside it (same fill, border, height and corners), with the + // text field and the dropdown arrow inside it, as in Excel. + let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover()); + // Painted once the field's focus and hover are known, but beneath the field and the arrow. + let bg = ui.painter().add(egui::Shape::Noop); + let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom())); + let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0)); + let mut field_ui = + ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let out = egui::TextEdit::singleline(&mut text) + .id(field) + .font(theme::ui_font(12.5)) + .frame(egui::Frame::NONE) + .desired_width(text_rect.width() - 12.0) + .show(&mut field_ui); let resp = out.response; control_keytip(app, ui, &resp, "home.fontSize"); if invoke(app, "home.fontSize") { @@ -1140,13 +1160,17 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { } else { ui.data_mut(|d| d.remove::(draft)); } - let arrow = icon_button(ui, Icon::Chevron, t.text_dim, "Font Size", vec2(14.0, 24.0)); - ui.spacing_mut().item_spacing.x = gap; + let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect)); + let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size()); + let visuals = ui.style().visuals.clone(); + let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive }; + let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke }; + ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside)); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Font Size")); let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); - if let ComboResult::Picked(value) = list_combo(ui, &arrow, "size", ¤t, &items) + if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", ¤t, &items) && let Ok(v) = value.parse::() { act(app, "home.fontSize", json!({"size": v})); From 3272097e15252a29fe66ae313d5aa39045ac8336 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 12:02:55 +0200 Subject: [PATCH 027/446] Control: Go To and Select over the control channel scroll the active cell into view (fixes #966) --- crates/ui-egui/src/control.rs | 14 +++++++-- crates/ui-egui/tests/selection_scroll.rs | 40 +++++++++++++++++++++++- docs/control-protocol.md | 2 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea5cfae0..06c2ca894 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -71,6 +71,16 @@ fn wrap(r: Result) -> Outcome { } } +/// Runs a command for an agent. Commands that move the selection scroll the active cell into +/// view, as the Go To dialog, the Name Box and the arrow keys do. +fn run(app: &mut SheetApp, id: &str, params: Json) -> Result { + let r = app.run(id, params); + if r.is_ok() && matches!(id, "edit.goTo" | "edit.goToSpecial" | "selection.set" | "selection.move") { + app.grid.ensure_visible = true; + } + r +} + pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { let r = ctx.content_rect(); let v = app.view(); @@ -171,7 +181,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "engine.execute" | "command" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); - wrap(app.run(id, if params.is_null() { json!({}) } else { params })) + wrap(run(app, id, if params.is_null() { json!({}) } else { params })) } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); @@ -381,7 +391,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ok(Json::Null) } // Anything else that names a command runs it. - other if gridcraft_engine::find_command(other).is_some() => wrap(app.run(other, p.clone())), + other if gridcraft_engine::find_command(other).is_some() => wrap(run(app, other, p.clone())), other => err(format!("unknown method `{other}`")), }; ctx.request_repaint(); diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs index 06e0d18ce..719f1a0f2 100644 --- a/crates/ui-egui/tests/selection_scroll.rs +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -1,10 +1,12 @@ //! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. +//! Go To and Select over the control channel scroll the active cell into view. use egui::{Event, Key, Modifiers}; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::control::{Outcome, handle}; use gridcraft_ui_egui::{SheetApp, grid::Geo}; -use serde_json::json; +use serde_json::{Value as Json, json}; type Harness = egui_kittest::Harness<'static, SheetApp>; @@ -104,3 +106,39 @@ fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { key(&mut h, Key::Home, CTRL_SHIFT); assert_extended(&h, "CV200", "A1"); } + +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + let out = match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + }; + h.run_steps(2); + out +} + +fn active(h: &Harness) -> CellRef { + h.state().session.active().unwrap().selection.active +} + +#[test] +fn go_to_over_the_control_channel_scrolls_the_target_into_view() { + let mut h = harness(); + assert!(!visible(&h, "Z300")); + let r = control(&mut h, "engine.execute", json!({"command": "edit.goTo", "params": {"reference": "Z300"}})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(active(&h), CellRef::parse("Z300").unwrap()); + assert!(visible(&h, "Z300"), "Go To scrolls Z300 into view"); + assert!(!visible(&h, "A1")); + // A command named as the method runs the same way. + control(&mut h, "edit.goTo", json!({"reference": "A1"})); + assert!(visible(&h, "A1") && !visible(&h, "Z300")); +} + +#[test] +fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { + let mut h = harness(); + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"range": "BZ150:CA160"}})); + assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); + assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..6bc765aeb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,7 +19,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | Method | Params | What it does | |---|---|---| -| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`) | +| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`). `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window | | `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) | | `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password | | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | From 4d5360fd90eb6afe8ad2f31c02cf616d60475b07 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 12:21:52 +0200 Subject: [PATCH 028/446] Sheet tabs: Ctrl-drag (Cmd or Option on macOS) drops a copy of the sheet (fixes #971) --- crates/ui-egui/src/tabs.rs | 79 +++++++++++++++++++++++++++++++++++--- docs/ui-parity.md | 5 ++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..3c7ece203 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -35,6 +35,26 @@ fn activate_sheet(app: &mut SheetApp, i: usize) { } } +/// A tab released with Ctrl held (Cmd on macOS, as for the fill handle) drops a copy of the sheet. Excel for Mac uses +/// Option for this, so Option copies there too. +fn drop_copies_sheet(m: egui::Modifiers, mac: bool) -> bool { + m.command || (mac && m.alt) +} + +/// `sheet.move` params for tab `i` dropped `steps` tabs away, or `None` when it lands where it started. +fn tab_drop(i: usize, steps: i64, copy: bool) -> Option { + if steps == 0 { + return None; + } + let to = (i as i64 + steps).max(0) as usize; + if copy { + // A move indexes the list without the sheet, a copy the list with it: to the right, the copy goes one further. + let to = if steps > 0 { to + 1 } else { to }; + return Some(json!({"sheet": i, "to": to, "copy": true})); + } + Some(json!({"sheet": i, "to": to})) +} + pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -155,15 +175,18 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - // Drag to reorder. + // Drag to reorder; with the copy modifier held, drop a copy instead. + let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); + if resp.dragged() && copy { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); + } if resp.drag_stopped() && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) { let dx = p.x - r.center().x; let steps = (dx / w).round() as i64; - if steps != 0 { - let to = (i as i64 + steps).max(0) as usize; - app.run_or_alert("sheet.move", json!({"sheet": i, "to": to})); + if let Some(params) = tab_drop(i, steps, copy) { + app.run_or_alert("sheet.move", params); } } resp.context_menu(|ui| { @@ -340,10 +363,56 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::{navigator_items, rename_needs_focus}; + use super::{drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop}; + use crate::SheetApp; + use egui::Modifiers; use gridcraft_engine::model::{Sheet, Visibility}; + use serde_json::json; use std::sync::Arc; + fn workbook_abc() -> SheetApp { + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("home.insertSheet", json!({})).unwrap(); + app.run("home.insertSheet", json!({})).unwrap(); + for (i, name) in ["A", "B", "C"].into_iter().enumerate() { + app.run("sheet.rename", json!({"sheet": i, "name": name})).unwrap(); + } + app + } + + fn names(app: &SheetApp) -> Vec { + app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect() + } + + #[test] + fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() { + // egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS. + assert!(drop_copies_sheet(Modifiers::CTRL | Modifiers::COMMAND, false)); + assert!(!drop_copies_sheet(Modifiers::ALT, false)); + assert!(drop_copies_sheet(Modifiers::MAC_CMD | Modifiers::COMMAND, true)); + assert!(drop_copies_sheet(Modifiers::ALT, true)); + assert!(!drop_copies_sheet(Modifiers::CTRL, true), "Ctrl+click is the context menu on macOS"); + assert!(!drop_copies_sheet(Modifiers::NONE, false)); + } + + #[test] + fn tab_dropped_with_the_copy_modifier_leaves_a_copy_where_a_move_would_land() { + let mut app = workbook_abc(); + app.run("sheet.move", tab_drop(0, 1, true).unwrap()).unwrap(); + assert_eq!(names(&app), ["A", "B", "A (2)", "C"]); + + let mut app = workbook_abc(); + app.run("sheet.move", tab_drop(2, -1, true).unwrap()).unwrap(); + assert_eq!(names(&app), ["A", "C (2)", "B", "C"]); + + let mut app = workbook_abc(); + app.run("sheet.move", tab_drop(0, 1, false).unwrap()).unwrap(); + assert_eq!(names(&app), ["B", "A", "C"]); + assert!(tab_drop(1, 0, true).is_none()); + } + #[test] fn rename_field_takes_focus_once_and_lets_go_on_commit() { assert!(rename_needs_focus(false, false), "first frame: grab focus"); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..7288fcfec 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Ctrl/Option-drag a sheet tab drops a copy, #971) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -14,6 +14,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Element | Excel | GridCraft | Status | |---|---|---|---| +| 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) | | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | @@ -21,7 +22,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | From a98239e1d12542fd7bc7440eba4b515d9e97cb72 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:05:51 -0500 Subject: [PATCH 029/446] Format Cells: take the documented camelCase style keys home.formatCells documents {style: {numFmt?, ...}}, but the style patch was merged with the style's snake_case field names, so numFmt (and borders.diagUp/diagDown) were silently ignored. camelCase keys in the patch are now renamed to the style's own; snake_case keys still work. --- crates/engine/src/cmd/format.rs | 42 ++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 4712351ca..fd3993e1d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -488,7 +488,7 @@ fn borders(s: &mut Session, p: &Json) -> Result { } fn format_cells(s: &mut Session, p: &Json) -> Result { - let Some(patch) = p.get("style").cloned() else { + let Some(patch) = p.get("style").map(snake_case_keys) else { // Menu invocation without params: the UI opens the dialog. s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({}))); return ok(); @@ -506,6 +506,32 @@ fn format_cells(s: &mut Session, p: &Json) -> Result { }) } +/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the +/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay. +fn snake_case_keys(v: &Json) -> Json { + match v { + Json::Object(m) => Json::Object( + m.iter() + .map(|(k, v)| { + let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) { + k.chars().fold(String::new(), |mut out, c| { + if c.is_ascii_uppercase() { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + out + }) + } else { + k.clone() + }; + (key, snake_case_keys(v)) + }) + .collect(), + ), + other => other.clone(), + } +} + /// Deep-merges `patch` into `base` (objects merge, everything else replaces). pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json { match (&mut base, patch) { @@ -1149,6 +1175,20 @@ mod tests { assert_eq!(adjust_decimals("0%", &v, 1), "0.0%"); } + #[test] + fn format_cells_takes_the_documented_keys() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap(); + let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]; + assert_eq!(st["num_fmt"], "0.0"); + assert_eq!(st["borders"]["diag_up"]["style"], "Thin"); + // The style's own snake_case keys keep working. + s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00"); + } + #[test] fn width_units() { assert_eq!(chars_to_points(8.43), 64.0); From 785913ed47f7c4f33d098a69f1345240b16b54b7 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:46:21 -0500 Subject: [PATCH 030/446] Flash Fill: read the text cells show Flash Fill read stored values, so a date was the serial number 45306, not 2024-01-15: with "15/01/2024" given for 2024-01-15 it found no part of the source in the example and filled every row with "15/01/2024" again. It now reads the cells' displayed text, as Excel does. --- crates/engine/src/fill.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..a80513577 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -442,13 +442,13 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result { // Header row: skip a first row whose target is text and source looks like a header? Keep simple. let mut examples = Vec::new(); let mut todo = Vec::new(); + // Flash Fill works on the text cells show (a date as formatted, not its serial number). for r in top..=bottom { - let src = sh.value(CellRef::new(r, src_col)).display(); - let dst = sh.value(CellRef::new(r, at.col)); - if dst.is_empty() { + let src = crate::display::cell_text(&cx.wb, sh, CellRef::new(r, src_col)); + if sh.value(CellRef::new(r, at.col)).is_empty() { todo.push((r, src)); } else { - examples.push((src, dst.display())); + examples.push((src, crate::display::cell_text(&cx.wb, sh, CellRef::new(r, at.col)))); } } if examples.is_empty() { @@ -603,6 +603,18 @@ fn synthesize(src: &str, dst: &str) -> Vec { mod tests { use super::*; + #[test] + fn flash_fill_reads_shown_text() { + let mut s = crate::Session::new(); + s.new_workbook(); + let values = serde_json::json!([["2024-01-15", "15/01/2024"], ["2023-12-03", null]]); + s.execute("range.setValues", serde_json::json!({"range": "A1", "values": values})).unwrap(); + s.execute("selection.set", serde_json::json!({"cell": "B2"})).unwrap(); + s.execute("data.flashFill", serde_json::json!({})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023")); + } + #[test] fn flash_programs() { let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap(); From 260da4e7527bca8cf4651ce47f3101cd858bf317 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:28:57 -0500 Subject: [PATCH 031/446] Recalc: an array that spills as before doesn't requeue its dependents After each round, every array formula that was evaluated queued the dependents of its spill area again, even when it spilled the same area with the same values. Two arrays that read each other's spill areas then queued each other in every round, up to the limit of 8: one edit evaluated 24 formulas instead of 3. An unchanged spill now leaves its dependents alone. last_recalc_cells counts the formulas of all rounds. --- crates/calc/src/recalc.rs | 25 ++++++++++++++++++++++++- crates/calc/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index b391821d7..a3daee641 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -456,6 +456,20 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) } +/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over +/// `range`: the same area, nothing new in the way, and the same values. +fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { + let end = + CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); + RangeRef::new(anchor, end) == range + && !sheet.merges.iter().any(|m| m.intersects(&range)) + && !sheet.tables.iter().any(|t| t.range.contains(anchor)) + && range + .iter() + .enumerate() + .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) +} + impl PassHost<'_> { fn compute(&mut self, k: Key) -> Value { if let Some(v) = self.results.get(&k) { @@ -950,6 +964,7 @@ impl Calc { /// Evaluates the dirty set and writes results (and spills) back. fn run(&mut self, wb: &mut Workbook, mut dirty: Vec, full: bool) { let mut dynamic_again = !self.graph.dynamic.is_empty(); + self.last_recalc_cells = 0; for _round in 0..8 { if dirty.is_empty() { break; @@ -984,7 +999,7 @@ impl Calc { eprintln!(" eval {} formulas {:.1} ms", results.len(), prof_now() - tp); } let tw = prof_now(); - self.last_recalc_cells = results.len(); + self.last_recalc_cells += results.len(); self.circular = cycles; // Write back. let mut spill_changes: Vec = Vec::new(); @@ -1004,6 +1019,14 @@ impl Calc { if old.is_none() && new.is_none() { continue; } + // An array that spills exactly as before changes nothing its dependents read, so + // they aren't queued again. Otherwise two arrays that read each other's spill areas + // queue each other in every round, up to the round limit. + if let (Some(r), Some(arr)) = (old, new) + && spills_as_before(sheet, k.1, r, arr) + { + continue; + } if let Some(r) = old { for c in r.iter() { sheet.spill.remove(&c); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..4529e1c19 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -67,6 +67,25 @@ fn spill_range_operator() { assert_eq!(t.num("E1"), 100.0); } +#[test] +fn arrays_that_spill_as_before_do_not_requeue_their_dependents() { + // Two arrays that each read the other's spill area, and a formula reading both spills. + let mut t = T::new(); + t.set("A1", "=SEQUENCE(2)+0*B2"); + t.set("B1", "=SEQUENCE(2)+0*A2"); + t.set("C1", "=SUM(A1#)+SUM(B1#)"); + // An edit that spills the same values again: one round, not one per round up to the limit. + t.set("A1", "=SEQUENCE(2)+0*B2+0"); + assert_eq!((t.num("A2"), t.num("B2"), t.num("C1")), (2.0, 2.0, 6.0)); + assert!(t.calc.last_recalc_cells <= 3, "{} formulas evaluated for one edit", t.calc.last_recalc_cells); + // Only the anchor's value changes: formulas reading the whole spill still see it. + t.set("A1", "={7;2}+0*B2"); + assert_eq!((t.num("A1"), t.num("C1")), (7.0, 12.0)); + // A spill that does change still reaches its dependents. + t.set("A1", "=SEQUENCE(2)*10+0*B2"); + assert_eq!((t.num("A2"), t.num("C1")), (20.0, 33.0)); +} + #[test] fn arithmetic_and_dependencies() { let mut t = T::new(); From 9939819ee335ef790fba762ddb1e47e971e2a602 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:03:55 -0500 Subject: [PATCH 032/446] rows/cols parameters: accept a single row or column home.insertRows {"rows":"3"} and home.insertColumns {"cols":"B"} failed with "not a range": only spans such as 3:3 or B:B parsed. The same went for deleting, hiding, sizing and grouping rows and columns. A single row number or column letter now means that one line. --- crates/engine/src/cmd/data.rs | 18 ++++++++++++++++-- crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/mod.rs | 5 +++++ crates/engine/src/cmd/sheet.rs | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 383624b70..3a2e476ac 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1127,10 +1127,10 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result { // ---------------------------------------------------------------- outline fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { - if let Some(r) = str_param(p, "rows").and_then(RangeRef::parse) { + if let Some(r) = str_param(p, "rows").and_then(parse_lines) { return Ok((true, r.start.row, r.end.row)); } - if let Some(r) = str_param(p, "cols").and_then(RangeRef::parse) { + if let Some(r) = str_param(p, "cols").and_then(parse_lines) { return Ok((false, r.start.col, r.end.col)); } let r = s.doc()?.selection.current(); @@ -1493,6 +1493,20 @@ mod tests { } } + #[test] + fn single_row_and_column_parameters() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); + s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "C3"), json!(4.0)); + s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); + s.execute("data.group", json!({"cols": "C"})).unwrap(); + assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); + } + #[test] fn split() { assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 4712351ca..b7f2e13f8 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -940,7 +940,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result> { return t .split(',') .map(|part| { - let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; + let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }) }) .collect(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..2be06318c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -246,6 +246,11 @@ pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result> { Ok(s.doc()?.selection.ranges.clone()) } +/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`. +pub(crate) fn parse_lines(t: &str) -> Option { + RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) }) +} + pub(crate) fn target_range(s: &Session, p: &Json) -> Result { Ok(target_ranges(s, p)?.first().copied().unwrap_or_default()) } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e..a81634e9a 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> { let key = if axis == Axis::Rows { "rows" } else { "cols" }; let r = match str_param(p, key).or_else(|| str_param(p, "range")) { - Some(t) => RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?, + Some(t) => parse_lines(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?, None => s.doc()?.selection.current(), }; let (a, b) = if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }; From 93338d06cee791b033f510baff1db690245c8f0a Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:52:10 -0500 Subject: [PATCH 033/446] LAMBDA helpers: #CALC! for nested arrays, #VALUE! for wrong parameter counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAP, BYROW, BYCOL, SCAN and MAKEARRAY put one value in each cell, but a LAMBDA returning several values silently gave its first one: MAP({1,2},LAMBDA(x,{1,2})) was {1,1} and BYROW(…,LAMBDA(r,r)) the first column. As in Excel, the whole result is now #CALC!, as arrays can't nest (a single-value array is still fine). MAP with a LAMBDA taking a different number of parameters than it has arrays, and BYROW or BYCOL with one taking other than one, ran anyway; Excel gives #VALUE!. --- crates/calc/src/eval.rs | 28 ++++++++++++++++++++++++---- crates/calc/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..dd50d832c 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1015,12 +1015,17 @@ impl<'h> Evaluator<'h> { if (rows * cols) as u64 > MAX_CELLS { return err(CellError::Num); } + // The LAMBDA takes one parameter per array. + if l.params.len() != arrays.len() { + return err(CellError::Value); + } let mut out = Vec::with_capacity(rows * cols); for r in 0..rows { for c in 0..cols { let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); let v = self.call_lambda_values(&l, bound); - out.push(self.deref(v).scalar()); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); } } Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) @@ -1041,7 +1046,8 @@ impl<'h> Evaluator<'h> { let r = self.call_lambda_values(&l, bound); acc = self.deref(r); if name == "SCAN" { - scan.push(acc.scalar()); + let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; + scan.push(one); } } if name == "SCAN" { @@ -1057,6 +1063,9 @@ impl<'h> Evaluator<'h> { let arr = as_array(self.value(&args[0])); let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; + if l.params.len() != 1 { + return err(CellError::Value); + } let by_row = name == "BYROW"; let n = if by_row { arr.rows } else { arr.cols }; let mut out = Vec::with_capacity(n); @@ -1068,7 +1077,8 @@ impl<'h> Evaluator<'h> { }; let a = if by_row { Array::row(slice) } else { Array::column(slice) }; let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); - out.push(self.deref(r).scalar()); + let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; + out.push(one); } let a = if by_row { Array::column(out) } else { Array::row(out) }; Ev::V(a.into()) @@ -1097,7 +1107,8 @@ impl<'h> Evaluator<'h> { (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), ]; let v = self.call_lambda_values(&l, bound); - out.push(self.deref(v).scalar()); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); } } Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) @@ -1655,6 +1666,15 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V Value::from(Array { rows, cols, data }) } +/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays +/// can't nest, so the whole helper gives `#CALC!`, as in Excel. +fn single(v: Value) -> Option { + match v { + Value::Array(a) if a.rows * a.cols > 1 => None, + v => Some(v.scalar()), + } +} + pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..efef610d7 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,29 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +#[test] +fn lambda_helpers_check_shapes_and_parameters() { + let mut t = T::new(); + let calc = Value::Error(CellError::Calc); + // A LAMBDA returning several values can't fill one cell: the whole result is #CALC!. + t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))"); + assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty)); + t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))"); + assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty)); + t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))"); + assert_eq!(t.get("A4"), calc.clone()); + t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))"); + assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty)); + // One parameter per array (MAP) or exactly one (BYROW/BYCOL). + t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))"); + assert_eq!(t.get("A5"), Value::Error(CellError::Value)); + t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))"); + assert_eq!(t.get("A6"), Value::Error(CellError::Value)); + // A single value in an array is fine. + t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))"); + assert_eq!(t.num("B7"), 2.0); +} + #[test] fn operators() { let mut t = T::new(); From c05285e181d075f2432b8c98487669f7f4c79332 Mon Sep 17 00:00:00 2001 From: pourie-he Date: Tue, 13 Oct 2026 23:12:38 +1100 Subject: [PATCH 034/446] UI: support Command and Shift clicks in formula pointing --- ROADMAP.md | 3 +- contributors/people.toml | 3 + crates/ui-egui/src/editor.rs | 22 ++++- crates/ui-egui/src/formula_bar.rs | 1 + crates/ui-egui/src/grid.rs | 36 ++++++--- crates/ui-egui/tests/selection.rs | 129 +++++++++++++++++++++++++++++- docs/ui-parity.md | 5 +- 7 files changed, 182 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2e0dbc593 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; readiness unchanged) · **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. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/contributors/people.toml b/contributors/people.toml index d522b84e0..794f4803a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.pourie-he] +sync_github_name = true diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 4aada2953..597378a08 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -32,6 +32,8 @@ pub struct EditState { pub point: Option<(usize, usize)>, /// Where point mode's keyboard cursor is. pub point_cell: Option, + /// The fixed range anchor and its sheet while pointing with the mouse or keyboard. + pub point_anchor: Option<(usize, CellRef)>, pub autocomplete: Vec, pub ac_index: usize, pub request_focus: bool, @@ -56,6 +58,7 @@ impl EditState { caret, point: None, point_cell: None, + point_anchor: None, autocomplete: vec![], ac_index: 0, request_focus: true, @@ -81,9 +84,7 @@ impl EditState { if !self.is_formula() { return false; } - if let Some((_, e)) = self.point - && e == self.byte_caret() - { + if self.point_at_caret() { return true; } let before = self.text.get(..self.byte_caret()).unwrap_or(""); @@ -91,6 +92,10 @@ impl EditState { matches!(last, Some('=' | '(' | ',' | '+' | '-' | '*' | '/' | '^' | '&' | '<' | '>' | ':' | ';' | '{')) } + pub fn point_at_caret(&self) -> bool { + self.point.is_some_and(|(start, end)| end == self.byte_caret() && self.text.get(start..end).is_some()) + } + /// Inserts (or replaces the last pointed) reference text at the caret. pub fn insert_ref(&mut self, r: &str) { let caret_b = self.byte_caret(); @@ -107,6 +112,17 @@ impl EditState { self.sync_caret = true; } + /// Add another argument after the current pointed reference. Keep only the new reference + /// replaceable, so a subsequent plain click does not remove earlier arguments or the separator. + pub fn append_ref(&mut self, r: &str) { + let separator = if self.point_at_caret() { self.locale.list_separator().to_string() } else { String::new() }; + self.point = None; + self.insert_ref(&format!("{separator}{r}")); + if let Some((start, end)) = self.point { + self.point = Some((start + separator.len(), end)); + } + } + /// Updates function autocomplete for the word before the caret. pub fn update_autocomplete(&mut self, names: &[String]) { self.autocomplete.clear(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..c791487e6 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -319,6 +319,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text); } ed.point = None; + ed.point_anchor = None; let names: Vec = function_names() .iter() .map(|name| { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..92f29a539 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2067,14 +2067,26 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if let Some(ed) = app.editor.as_mut() && ed.can_point() { + let anchor = if mods.shift && ed.point_at_caret() { + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c) + } else { + c + }; + let range = RangeRef::new(anchor, c); + let text = if range.is_single() { c.a1() } else { range.a1() }; let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone()); let r = match sheet_name { - Some(n) => format!("{}!{}", gridcraft_engine::formula::quote_sheet(&n), c.a1()), - None => c.a1(), + Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), + None => text, }; - ed.insert_ref(&r); + if mods.command && !mods.shift { + ed.append_ref(&r); + } else { + ed.insert_ref(&r); + } ed.request_focus = true; ed.point_cell = Some(c); + ed.point_anchor = Some((wb.active_sheet, anchor)); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2336,6 +2348,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) let r = range.a1(); let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; ed.insert_ref(&r); + ed.point_anchor = None; ed.request_focus = true; true } @@ -2348,12 +2361,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { - if let Some(start) = ed.point_cell { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) { let range = RangeRef::new(start, cell); let text = if range.is_single() { range.start.a1() } else { range.a1() }; let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; ed.insert_ref(&text); - ed.point_cell = Some(start); + ed.point_cell = Some(cell); + ed.point_anchor = Some((wb.active_sheet, start)); } } else if let Some(d) = app.session.active() { // Read the current anchor: starting a drag may have just replaced the selection. @@ -2929,11 +2943,11 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte let Some(d) = app.session.active() else { return }; let from = ed.point_cell.unwrap_or(ed.cell); let to = from.offset_clamped(dr, dc); - let text = if extend { + let anchor = if extend { // The anchor is the corner opposite the moving end: the written range is normalized, so // after extending up or left its first cell is the moving end, not the anchor. let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); - let anchor = pointed + let inferred_anchor = pointed .map(|r| { CellRef::new( if from.row == r.start.row { r.end.row } else { r.start.row }, @@ -2941,13 +2955,15 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ) }) .unwrap_or(from); - RangeRef::new(anchor, to).a1() + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor) } else { - to.a1() + to }; - let _ = d; + let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let sheet = d.wb.active_sheet; ed.insert_ref(&text); ed.point_cell = Some(to); + ed.point_anchor = Some((sheet, anchor)); app.grid.ensure_visible = true; } diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs index faebebf00..e168f3d25 100644 --- a/crates/ui-egui/tests/selection.rs +++ b/crates/ui-egui/tests/selection.rs @@ -53,6 +53,16 @@ fn press(h: &mut Harness, cell: &str) { frame(h, button(from, true)); } +fn click_with_modifiers(h: &mut Harness, cell: &str, modifiers: Modifiers) { + let pos = point(h, cell); + frame(h, Event::ModifiersChanged(modifiers)); + frame(h, Event::PointerMoved(pos)); + for pressed in [true, false] { + frame(h, Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + } + h.run_steps(2); +} + fn assert_selection(h: &Harness, start: &str, end: &str) { let selection = &h.state().session.active().unwrap().selection; let start = CellRef::parse(start).unwrap(); @@ -134,7 +144,8 @@ fn formula_point_mode_consumes_first_movement_and_release() { h.run_steps(1); let editor = h.state().editor.as_ref().unwrap(); assert_eq!(editor.text, "=C3:F7"); - assert_eq!(editor.point_cell, CellRef::parse("C3")); + assert_eq!(editor.point_cell, CellRef::parse("F7")); + assert_eq!(editor.point_anchor, Some((0, CellRef::parse("C3").unwrap()))); assert_eq!(cells(&h), before); } @@ -159,3 +170,119 @@ fn formula_point_drag_keeps_the_sheet_qualifier() { h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); assert_eq!(cells(&h), before); } + +#[test] +fn formula_modifier_command_click_appends_an_argument() { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + let command = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_modifier_shift_click_extends_the_pointed_range() { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3:F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_modifier_command_click_keeps_previous_arguments_replaceable_independently() { + for command in [Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }, Modifiers { ctrl: true, command: true, ..Modifiers::NONE }] { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "D4", command); + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,F7"); + click_with_modifiers(&mut h, "G7", Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,G7"); + click_with_modifiers(&mut h, "F9", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,F7:G9"); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn formula_modifier_shift_click_keeps_its_anchor_in_both_directions() { + for (start, first, second, expected) in [("C3", "F7", "B2", "B2:C3"), ("F7", "C3", "H9", "F7:H9")] { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, start, Modifiers::NONE); + click_with_modifiers(&mut h, first, Modifiers::SHIFT); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("=SUM({expected}")); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn formula_modifier_command_click_respects_locale_and_a_typed_separator() { + use gridcraft_engine::formula::FormulaLocale; + let command = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + for (locale, prefix, separator) in [(FormulaLocale::En, "=SUM(", ","), (FormulaLocale::Es, "=SUMA(", ";")] { + let mut h = harness("J10"); + h.state_mut().begin_edit(Some(prefix.into()), false); + h.state_mut().editor.as_mut().unwrap().locale = locale; + h.run_steps(2); + click_with_modifiers(&mut h, "C3", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3")); + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3{separator}F7")); + frame(&mut h, Event::ModifiersChanged(Modifiers::NONE)); + frame(&mut h, Event::Text(separator.into())); + h.run_steps(2); + assert!(h.state().editor.as_ref().unwrap().point_anchor.is_none()); + click_with_modifiers(&mut h, "G8", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3{separator}F7{separator}G8")); + } +} + +#[test] +fn formula_modifier_shift_click_does_not_reuse_an_anchor_from_another_sheet() { + let mut h = harness("J10"); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM('Other sheet'!F7"); + click_with_modifiers(&mut h, "D4", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM('Other sheet'!D4:F7"); +} + +#[test] +fn formula_modifier_shift_click_then_arrow_moves_from_the_clicked_endpoint() { + use egui::Key; + for (modifiers, expected) in [(Modifiers::SHIFT, "=SUM(C3:G7"), (Modifiers::NONE, "=SUM(G7")] { + let mut h = harness("J10"); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + frame(&mut h, Event::ModifiersChanged(modifiers)); + for pressed in [true, false] { + frame(&mut h, Event::Key { key: Key::ArrowRight, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); + assert_eq!(h.state().editor.as_ref().unwrap().text, expected); + assert_eq!(h.state().editor.as_ref().unwrap().point_cell, CellRef::parse("G7")); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..26eaa7f70 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (formula-entry Command/Ctrl-click adds arguments; Shift-click extends a range; horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -48,7 +48,7 @@ this logic, with one undo step. | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | @@ -163,6 +163,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. |---|---|---| | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | +| 2026-10-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | From f8838b15e13a943ea6755b80842439db2634a64d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 14:32:23 +0200 Subject: [PATCH 035/446] Keyboard: an arrow that commits typing ends the run of Tabs, as in Excel Typing then Right or Down committed through the Enter/Tab path, so Right continued the run and Down went back to its starting column. Found by @richard1912 against Excel 365: from B2, type, Tab, type, Right, Enter lands on D3, and with Down on C4. --- crates/ui-egui/src/formula_bar.rs | 8 +++++++- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/tests/interaction.rs | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 83278b190..4c83eb033 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -162,6 +162,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); let mut action: Option<(i64, i64, bool, bool)> = None; + let mut arrow = false; let mut cancel = false; if has_focus && mine { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { @@ -243,6 +244,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: crate::grid::point_move(app, &mut ed, dr, dc, ashift); } else { action = Some((dr, dc, false, false)); + arrow = true; } } // F4 cycles $ anchors of the reference before the caret. @@ -407,7 +409,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if cancel { app.cancel_edit(); } else if let Some((dr, dc, array, fill)) = action { - app.commit_edit(dr, dc, array, fill); + if arrow { + app.commit_edit_arrow(dr, dc); + } else { + app.commit_edit(dr, dc, array, fill); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 189fe106b..4da3dbf4e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -947,6 +947,15 @@ impl SheetApp { } } + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs + /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. + pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { + let run = self.tab_run.take(); + let ok = self.commit_edit(dr, dc, false, false); + self.tab_run = if ok { None } else { run }; + ok + } + pub fn cancel_edit(&mut self) { self.editor = None; self.session.mode = gridcraft_engine::Mode::Ready; diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 04e08d8b3..7c30e8b38 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -268,6 +268,24 @@ fn moving_with_an_arrow_ends_the_run_of_tabs() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C2"); } +#[test] +fn an_arrow_that_commits_typing_ends_the_run_of_tabs() { + // Excel 365 from B2: the arrow is an ordinary move, neither one more Tab (Right) nor an Enter + // back to the starting column (Down). + for (arrow, after_enter) in [(Key::ArrowRight, "D3"), (Key::ArrowDown, "C4")] { + let mut s = blank(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = harness(s); + text(&mut h, "a"); + key(&mut h, Key::Tab, Modifiers::NONE); + text(&mut h, "b"); + key(&mut h, arrow, Modifiers::NONE); + assert_eq!(value(&h, "C2"), Value::from("b"), "{arrow:?}"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), after_enter, "{arrow:?}"); + } +} + #[test] fn arrows_escape_and_undo() { let mut h = harness(blank()); From 482c3e94817e94d168b736d69b98b529a51f9698 Mon Sep 17 00:00:00 2001 From: quantmind-br Date: Tue, 13 Oct 2026 09:38:41 -0300 Subject: [PATCH 036/446] Engine: formulas.evaluate refuses what cell entry refuses, saying why `formulas.evaluate` (behind `gridcraft-cli eval` and MCP's `evaluate_formula`) turned any formula that didn't parse into #NAME?, so `=SUM(1;2)` looked like an unknown function. It now parses the way `cell.set` does (missing closing parentheses are added) and returns the same errors: "There's a problem with this formula: expected `,` or `)`, found Semicolon", or the wrong-argument-count message. An unknown function still evaluates to #NAME?, as in Excel. --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 30 +++++++++++++++++------------- crates/engine/src/cmd/formulas.rs | 9 +++++++-- crates/engine/src/tests.rs | 18 ++++++++++++++++++ crates/mcp/src/tools.rs | 3 ++- docs/cli.md | 2 +- docs/mcp.md | 2 +- 7 files changed, 47 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..555b7171d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..5570bf3fa 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -138,18 +138,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); if is_formula { let body = input.strip_prefix('=').unwrap_or(input); - // Excel closes missing parentheses for you. - let mut text = body.to_string(); - let mut parsed = gridcraft_formula::parse(&text); - for _ in 0..8 { - if parsed.is_ok() { - break; - } - text.push(')'); - parsed = gridcraft_formula::parse(&text); - } - let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?; - let expr = wb.name_call_case(expr); + let expr = wb.name_call_case(parse_typed_formula(body)?); check_arg_counts(&expr)?; let mut cell = Cell::formula(Formula::from_expr(expr)); cell.style = style; @@ -191,11 +180,26 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri Ok(Some(Cell { value: parsed.value, formula: None, style })) } +/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, +/// and refuses a formula that still doesn't parse. +pub(crate) fn parse_typed_formula(body: &str) -> Result { + let mut text = body.to_string(); + let mut parsed = gridcraft_formula::parse(&text); + for _ in 0..8 { + if parsed.is_ok() { + break; + } + text.push(')'); + parsed = gridcraft_formula::parse(&text); + } + parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) +} + /// Like Excel, a formula that calls a built-in function with too few or too many arguments is /// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't /// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and /// unknown functions are skipped. -fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { +pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { let mut bad = None; expr.walk(&mut |e| { if bad.is_none() diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 40df72df0..5f1be0162 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula), - cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate), + cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate), cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now), cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now), cmd!( @@ -540,11 +540,16 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)})) } +/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it +/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says +/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel. fn evaluate(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f); + let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?; + crate::cmd::edit::check_arg_counts(&expr)?; + let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr); Ok(crate::cmd::inspect::value_json(&v)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..1de25d2f3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,24 @@ fn formulas_with_the_wrong_argument_count_are_refused_on_entry() { assert_eq!(v(&s, "C1"), Value::text("=DATEDIF(1,2)")); } +#[test] +fn evaluate_refuses_what_entry_refuses_and_says_why() { + // `formulas.evaluate` (the CLI's `eval`, MCP's evaluate_formula) gave #NAME? for any formula + // that didn't parse, which hid the cause: `=SUM(1;2)`, with the `;` a French or German Excel + // uses, looked like an unknown function. + let mut s = s(); + let eval = |s: &mut Session, f: &str| s.execute("formulas.evaluate", json!({"formula": f})); + let e = eval(&mut s, "=SUM(1;2)").unwrap_err().to_string(); + assert!(e.contains("There's a problem with this formula") && e.contains("expected `,`"), "{e}"); + let e = eval(&mut s, "=DATEDIF(1,2)").unwrap_err().to_string(); + assert!(e.contains("too few arguments"), "{e}"); + // What entry accepts evaluates: an unknown function is #NAME?, as in Excel, missing closing + // parentheses are added, and the `=` is optional. + assert_eq!(eval(&mut s, "=SOMME(1,2)").unwrap(), json!({"error": "#NAME?"})); + assert_eq!(eval(&mut s, "=SUM(1,2").unwrap(), json!(3.0)); + assert_eq!(eval(&mut s, "1/4").unwrap(), json!(0.25)); +} + #[test] fn undo_redo() { let mut s = s(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e6322f016..a6879b3ff 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -196,7 +196,8 @@ pub fn tool_definitions() -> Vec { "evaluate_formula", "Evaluate formula", "Evaluate a formula against the active sheet WITHOUT changing any cell, e.g. \"=SUM(B2:B10)/COUNT(B2:B10)\" or \ - \"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN().", + \"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN(). \ + Use English function names and `,` between arguments; a formula that couldn't be entered in a cell is an error saying why.", obj( json!({"formula": string("Formula text, with or without the leading ="), "cell": string("Evaluate as if in this cell (default: active cell)")}), &["formula"], diff --git a/docs/cli.md b/docs/cli.md index ec9578d60..ad56f0d56 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- |---|---| | `info [--json]` | `gridcraft-cli info book.xlsx` — sheets, used ranges, cell counts, tables, charts, names. | | `convert [--sheet NAME]` | `gridcraft-cli convert book.xlsx data.csv --sheet Sales` — output format from the extension: `.xlsx`, `.csv`, `.tsv`, `.json`, `.html`. | -| `eval [--in FILE] [--sheet S] [--cell A1] [--json]` | `gridcraft-cli eval '=PMT(5%/12,360,-300000)'`; `gridcraft-cli eval '=SUM(B2:B9)' --in book.xlsx` | +| `eval [--in FILE] [--sheet S] [--cell A1] [--json]` | `gridcraft-cli eval '=PMT(5%/12,360,-300000)'`; `gridcraft-cli eval '=SUM(B2:B9)' --in book.xlsx` — formulas in English with `,` between arguments. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why; an unknown function is `#NAME?`, as in Excel. | | `cat [--range R] [--sheet S] [--formulas] [--csv]` | `gridcraft-cli cat book.xlsx --range A1:F20` — aligned table of displayed values. | | `check ... [--json] [--tolerance 1e-9] [--max-report N]` | `gridcraft-cli check corpus/` — calc oracle and XLSX round-trip fidelity, see below. | | `run …` | see below | diff --git a/docs/mcp.md b/docs/mcp.md index 3e6e7bc11..1877cf228 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -45,7 +45,7 @@ with a file open. | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | | `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | | `set_cell` / `get_cell` | `cell`, `input` / `cell` | Enter one cell / read value, formula, display text, style. | -| `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. | +| `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why. | | `select` | `range`, `active?` | Sets the selection. | | `format_range` | `range`, `bold?`, `italic?`, `underline?`, `fontName?`, `fontSize?`, `fontColor?`, `fillColor?`, `numberFormat?`, `horizontalAlign?`, `wrapText?`, `borders?` | Composes the `home.*` formatting commands. | | `insert_chart` | `range`, `type`, `title?`, `subtype?`, `at?` | `insert.chart`. | From 2d0a7a1e42c04f1c772e8294ec701ca0dfe6bde6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 14:48:32 +0200 Subject: [PATCH 037/446] Ribbon: more currency symbols in the Accounting Number Format menu (fixes #1063) --- crates/ui-egui/src/ribbon.rs | 71 ++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..e4a2cbe57 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -478,6 +478,21 @@ pub(crate) fn editable_combo( result } +/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. +const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[ + ("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), + ("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"), + ("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"), + ("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"), + ("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"), + ("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"), + ("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"), + ("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"), + ("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"), + ("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"), + ("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"), +]; + fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { for (label, id, p) in items { if *label == "-" { @@ -793,30 +808,10 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.accounting", json!({})); } egui::Popup::menu(&ca).show(|ui| { - menu_items( - app, - ui, - &[ - ( - "$ English (United States)", - "home.numberFormat", - json!({"code": "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"}), - ), - ( - "£ English (United Kingdom)", - "home.numberFormat", - json!({"code": "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"}), - ), - ( - "€ Euro", - "home.numberFormat", - json!({"code": "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"}), - ), - ("¥ Japanese", "home.numberFormat", json!({"code": "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"})), - ("-", "", json!(null)), - ("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"})), - ], - ); + let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect(); + items.push(("-", "", json!(null))); + items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"}))); + menu_items(app, ui, &items); }); let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)); control_keytip(app, ui, &__k, "home.percent"); @@ -2514,3 +2509,31 @@ mod checkbox_on_tests { assert!(!checkbox_on(false, true)); } } + +#[cfg(test)] +mod accounting_symbol_tests { + use super::*; + use gridcraft_engine::core::{DateSystem, Value}; + use gridcraft_engine::numfmt::{NumberFormat, format_value}; + + #[test] + fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() { + // #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the + // `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals. + for (label, tag, shown) in [ + ("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"), + ("₹ English (India)", "[$₹-4009]", "₹1,234.50"), + ("₩ Korean", "[$₩-412]", "₩1,235"), + ("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"), + ("¥ Japanese", "[$¥-411]", "¥1,235"), + ] { + let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c); + let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") }; + assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}"); + let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900); + assert_eq!(out.text.trim(), shown, "{label}"); + let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default(); + assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}"); + } + } +} From d3cc40ca20d138b8b6a61503bd6b04e81d2e974d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:56:53 +0200 Subject: [PATCH 038/446] =?UTF-8?q?Colour=20menus:=20More=20Colors?= =?UTF-8?q?=E2=80=A6=20and=20Recent=20Colors=20(#928)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette every colour menu shares ends with the custom colours last used (Recent Colors, newest first, ten at most, each once) and a More Colors… button that opens a picker in the menu: the saturation/value area with its hue bar, egui's red-green-blue boxes, a hex box (#RRGGBB, RRGGBB, #RGB) with the new colour beside it, OK and Cancel. OK applies the colour like a swatch and remembers it. The ribbon's Fill Color and Font Color menus close only on a click outside them or after a pick, so the picker stays open while it is used; the Format Cells pickers show it inline. The tab-colour and chart-series menus are sub-menus that close on any click inside, so they show Recent Colors but not the picker. --- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/ribbon.rs | 38 +++--- crates/ui-egui/src/tabs.rs | 2 +- crates/ui-egui/src/widgets.rs | 179 ++++++++++++++++++++++++++-- crates/ui-egui/tests/more_colors.rs | 99 +++++++++++++++ 5 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 crates/ui-egui/tests/more_colors.rs diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index aaa9bec32..a8a7e35f4 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -241,7 +241,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { ui.label(&name); ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110))); ui.menu_button("Fill…", |ui| { - if let Some(c) = crate::widgets::color_palette(ui, &theme.colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") { app.run_or_alert( "chart.formatSelection", json!({"chart": id, "series": i, "color": if c == "none" { Json::Null } else { json!(c) }}), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..70a312ad1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -669,15 +669,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.fillColor", json!({"color": fill})); } let forced = invoke(app, "home.fillColor"); - egui::Popup::menu(&fa).open_memory(popup_command(forced, fa.clicked())).show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { - if c != "none" { - app.grid.last_fill = c.clone(); + egui::Popup::menu(&fa) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, fa.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { + if c != "none" { + app.grid.last_fill = c.clone(); + } + act(app, "home.fillColor", json!({"color": c})); + ui.close(); } - act(app, "home.fillColor", json!({"color": c})); - ui.close(); - } - }); + }); let fc = app.grid.last_font_color.clone(); let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); @@ -686,15 +689,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.fontColor", json!({"color": fc})); } let forced = invoke(app, "home.fontColor"); - egui::Popup::menu(&ca).open_memory(popup_command(forced, ca.clicked())).show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { - if c != "none" { - app.grid.last_font_color = c.clone(); + egui::Popup::menu(&ca) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, ca.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { + if c != "none" { + app.grid.last_font_color = c.clone(); + } + act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); + ui.close(); } - act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); - ui.close(); - } - }); + }); }); }); sep(ui); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..d24ed48e7 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -186,7 +186,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } ui.menu_button("Tab Color", |ui| { let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, "No Color") { app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); ui.close(); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b49..eefe983ce 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -144,23 +144,84 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option String { + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) +} + +/// A colour typed as `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, spaces around it ignored). +pub fn parse_hex(text: &str) -> Option<[u8; 3]> { + let t = text.trim(); + let t = t.strip_prefix('#').unwrap_or(t); + let digits: Vec = t.chars().map(|c| c.to_digit(16)).collect::>()?; + let byte = |hi: u32, lo: u32| u8::try_from(hi * 16 + lo).ok(); + match digits[..] { + [r, g, b] => Some([byte(r, r)?, byte(g, g)?, byte(b, b)?]), + [r1, r2, g1, g2, b1, b2] => Some([byte(r1, r2)?, byte(g1, g2)?, byte(b1, b2)?]), + _ => None, + } +} + +/// Puts `hex` first in the recent colours, once, keeping at most [`RECENT_COLORS`]. +pub fn remember_color(recent: &mut Vec, hex: &str) { + let hex = hex.to_ascii_uppercase(); + recent.retain(|h| *h != hex); + recent.insert(0, hex); + recent.truncate(RECENT_COLORS); +} + +fn recent_colors_id() -> egui::Id { + egui::Id::new("gridcraft.recent_colors") +} + +/// The More Colors… picker's state while it is open in a palette. +#[derive(Clone)] +struct MoreColors { + hsva: egui::ecolor::Hsva, + hex: String, +} + +impl MoreColors { + fn new(rgb: [u8; 3]) -> Self { + Self { hsva: egui::ecolor::Hsva::from_srgb(rgb), hex: hex_of(rgb) } + } +} + +/// Our colour palette (theme row + tints + standard colours, then Recent Colors and More Colors…), +/// returns a picked hex colour, `Some("none")` for No Fill/Automatic. Arrow keys move a highlight +/// through the swatches and Enter applies; the key events are stripped so the grid behind the popup +/// does not also react (not while More Colors… is open, whose boxes take the keys). pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { + palette(ui, theme_colors, none_label, true) +} + +/// [`color_palette`] without More Colors…, for a menu that closes on any click inside it (a sub-menu +/// of a context menu), where the picker would vanish as soon as it is used. Recent Colors stays. +pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { + palette(ui, theme_colors, none_label, false) +} + +fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option { let mut picked = None; const ROWS: usize = 7; // 6 tint rows + 1 standard row const COLS: usize = 10; let pal_id = ui.id().with("__color_palette"); let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None); + let more_id = pal_id.with("more"); + let mut more: Option = ui.data_mut(|d| d.get_temp(more_id)).unwrap_or(None); let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false); - ui.input(|i| { - dl = i.key_pressed(egui::Key::ArrowLeft); - dr = i.key_pressed(egui::Key::ArrowRight); - du = i.key_pressed(egui::Key::ArrowUp); - dd = i.key_pressed(egui::Key::ArrowDown); - ent = i.key_pressed(egui::Key::Enter); - }); + if more.is_none() { + ui.input(|i| { + dl = i.key_pressed(egui::Key::ArrowLeft); + dr = i.key_pressed(egui::Key::ArrowRight); + du = i.key_pressed(egui::Key::ArrowUp); + dd = i.key_pressed(egui::Key::ArrowDown); + ent = i.key_pressed(egui::Key::Enter); + }); + } if dl || dr || du || dd { cur = match cur { None => Some(( @@ -266,6 +327,73 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> } } }); + // Recent Colors: the custom colours picked below, newest first. + let mut recent: Vec = ui.ctx().data(|d| d.get_temp(recent_colors_id())).unwrap_or_default(); + if !recent.is_empty() { + ui.label(egui::RichText::new("Recent Colors").small()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for hex in &recent { + let c = parse_hex(hex).map_or(Color32::BLACK, |c| Color32::from_rgb(c[0], c[1], c[2])); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click()); + ui.painter().rect_filled(r, 1.0, c); + ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + } + if resp.on_hover_text(hex).clicked() { + picked = Some(hex.clone()); + } + } + }); + } + if more_colors { + ui.separator(); + } + if more_colors && ui.button("More Colors…").clicked() { + let start = recent.first().and_then(|h| parse_hex(h)).unwrap_or([0x44, 0x72, 0xC4]); + more = Some(MoreColors::new(start)); + } + if let Some(m) = more.as_mut() { + let mut done = false; + // The saturation / value area with its hue bar and egui's red-green-blue boxes, then the hex + // box with the new colour beside it. + if egui::color_picker::color_picker_hsva_2d(ui, &mut m.hsva, egui::color_picker::Alpha::Opaque) { + m.hex = hex_of(m.hsva.to_srgb()); + } + ui.horizontal(|ui| { + let label = ui.label("Hex"); + let r = ui.add(egui::TextEdit::singleline(&mut m.hex).desired_width(72.0).char_limit(7)).labelled_by(label.id); + if r.changed() + && let Some(c) = parse_hex(&m.hex) + { + m.hsva = egui::ecolor::Hsva::from_srgb(c); + } + let c = m.hsva.to_srgb(); + let (sw, _) = ui.allocate_exact_size(vec2(36.0, 18.0), Sense::hover()); + ui.painter().rect_filled(sw, 1.0, Color32::from_rgb(c[0], c[1], c[2])); + ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); + ui.label(egui::RichText::new("New").small()); + }); + ui.horizontal(|ui| { + if ui.button(" OK ").clicked() { + // A hex still being typed that is not a colour falls back to the picker's. + let c = parse_hex(&m.hex).unwrap_or_else(|| m.hsva.to_srgb()); + let hex = hex_of(c); + remember_color(&mut recent, &hex); + picked = Some(hex); + done = true; + } + if ui.button("Cancel").clicked() { + done = true; + } + }); + if done { + more = None; + } + } + ui.ctx().data_mut(|d| d.insert_temp(recent_colors_id(), recent)); + ui.data_mut(|d| d.insert_temp(more_id, more)); ui.data_mut(|d| d.insert_temp(pal_id, cur)); picked } @@ -362,3 +490,34 @@ pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { app.notice = None; } } + +#[cfg(test)] +mod color_tests { + use super::*; + + #[test] + fn hex_is_read_in_the_forms_people_type() { + assert_eq!(parse_hex("#1A2b3C"), Some([0x1A, 0x2B, 0x3C])); + assert_eq!(parse_hex(" 1a2b3c "), Some([0x1A, 0x2B, 0x3C])); + assert_eq!(parse_hex("#f80"), Some([0xFF, 0x88, 0x00])); + for bad in ["", "#", "#12", "#1234", "#12345", "#1234567", "#12345G", "##123456", "é12345"] { + assert_eq!(parse_hex(bad), None, "{bad:?}"); + } + assert_eq!(hex_of([0, 10, 255]), "#000AFF"); + } + + #[test] + fn recent_colours_keep_the_newest_first_once_each_and_at_most_ten() { + let mut r = Vec::new(); + for i in 0..12u8 { + remember_color(&mut r, &hex_of([i, 0, 0])); + } + assert_eq!(r.len(), RECENT_COLORS); + assert_eq!(r[0], "#0B0000"); + remember_color(&mut r, "#050000"); + remember_color(&mut r, "#0b0000"); + assert_eq!(&r[..2], ["#0B0000", "#050000"]); + assert_eq!(r.iter().filter(|h| *h == "#050000").count(), 1); + assert_eq!(r.len(), RECENT_COLORS); + } +} diff --git a/crates/ui-egui/tests/more_colors.rs b/crates/ui-egui/tests/more_colors.rs new file mode 100644 index 000000000..72704d50b --- /dev/null +++ b/crates/ui-egui/tests/more_colors.rs @@ -0,0 +1,99 @@ +//! The colour menus' More Colors… and Recent Colors, through the real egui input path. + +use egui::{Event, Key, Modifiers, os::OperatingSystem}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn press(h: &mut Harness, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + h.input_mut().events.extend([ev(true), ev(false)]); + h.run_steps(2); +} + +/// H, H opens the Fill Color palette. +fn open_fill_palette(h: &mut Harness) { + for k in [Key::F10, Key::H, Key::H] { + press(h, k, Modifiers::NONE); + } + h.run_steps(3); +} + +fn type_hex(h: &mut Harness, hex: &str) { + h.get_by_label("Hex").click(); + h.run_steps(2); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(hex.to_string())); + h.run_steps(3); +} + +#[test] +fn more_colors_applies_the_typed_colour_and_remembers_it() { + let mut h = harness(); + open_fill_palette(&mut h); + assert!(h.query_by_label("Recent Colors").is_none(), "nothing is recent before a custom colour is used"); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + type_hex(&mut h, "#12AB34"); + h.get_by_label(" OK ").click(); + h.run_steps(4); + assert_eq!(h.state().grid.last_fill, "#12AB34"); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1); + assert!(h.query_by_label("Theme Colors").is_none(), "the palette closes after OK"); + // The next time any colour menu opens, the colour is the first Recent Color. + open_fill_palette(&mut h); + h.get_by_label("Recent Colors"); + assert!(h.query_by_label("Hex").is_none(), "More Colors… starts closed"); +} + +#[test] +fn a_colour_that_is_not_hex_does_not_apply_and_cancel_applies_nothing() { + let mut h = harness(); + open_fill_palette(&mut h); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + // Enter in the Hex box does not apply the palette's highlighted swatch or close the menu. + type_hex(&mut h, "#12AB"); + press(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); + h.get_by_label("Hex"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); + assert!(h.query_by_label("Hex").is_none()); + assert!(h.query_by_label("Recent Colors").is_none(), "Cancel remembers nothing"); +} + +#[test] +fn the_font_colour_menu_has_them_too_and_shares_the_recent_colours() { + let mut h = harness(); + open_fill_palette(&mut h); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + type_hex(&mut h, "#00FF7F"); + h.get_by_label(" OK ").click(); + h.run_steps(4); + for k in [Key::F10, Key::H, Key::F, Key::C] { + press(&mut h, k, Modifiers::NONE); + } + h.run_steps(3); + h.get_by_label("Automatic"); + h.get_by_label("Recent Colors"); + h.get_by_label("More Colors…"); +} From dcef8f12b22ca71d83268357b3059d870cbd2ad2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:56:53 +0200 Subject: [PATCH 039/446] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/ui-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..b80839e7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..bc3a3ae35 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (More Colors… and Recent Colors (#928); previously minor: horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -15,7 +15,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Element | Excel | GridCraft | Status | |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | -| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | +| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928). | done; missing contextual tabs for pictures, shapes, sparklines, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | More Colors… and Recent Colors (#928) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | From 209d2447d943372c0142146b9cebc475bc98953b Mon Sep 17 00:00:00 2001 From: Lexx143 Date: Tue, 13 Oct 2026 17:27:49 +0300 Subject: [PATCH 040/446] Charts: multi-level category axes, read, drawn and written back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A category range two or more columns wide beside a column of values (year beside month, Excel's multiLvlStrRef) was read as one flat list: 36 rows in two columns became 72 categories, the series sat in the left half of the plot and the years and months shared one row of labels. Saving wrote the range as a plain strRef, so Excel lost the two-level axis too. - gridcraft_core::category_levels: the shape of a category range beside its values (levels are columns when the points run down the rows, rows when they run along the columns; up to three outer levels), shared by the chart and xlsx crates. - ChartData.category_levels: the outer levels, nearest the labels first, one entry per category, non-empty where a group starts. resolve fills it; a single column is unchanged. - render: the outer levels in rows under the labels, a group's name centred under its categories with a separator at each boundary; in bar charts in columns beside the labels. - xlsx: written as multiLvlStrRef with the level caches (innermost first), so the axis survives a round trip. Tests in core (shapes), chart (resolve and layout for columns, bars and a row of values) and xlsx (written XML, read-back, stability, a single column stays strRef). chart-parity, gaps § 4 and the ROADMAP progress log updated. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2 + crates/chart/src/lib.rs | 5 + crates/chart/src/render.rs | 80 +++++++++-- crates/chart/src/resolve.rs | 36 ++++- crates/chart/src/tests.rs | 131 ++++++++++++++++-- crates/core/src/addr.rs | 49 +++++++ crates/core/src/lib.rs | 2 +- crates/xlsx/src/chart.rs | 56 +++++++- crates/xlsx/src/tests/mod.rs | 1 + .../tests/multilevel_category_roundtrip.rs | 109 +++++++++++++++ docs/chart-parity.md | 6 +- docs/gaps.md | 3 +- 12 files changed, 439 insertions(+), 41 deletions(-) create mode 100644 crates/xlsx/src/tests/multilevel_category_roundtrip.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..9fe4875e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 697e2a7a7..4b68b8d17 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -123,6 +123,11 @@ impl Measure for ApproxMeasure { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ChartData { pub categories: Vec, + /// Outer levels of a multi-level category axis (a category range two columns wide beside a + /// column of values: year beside month), nearest the labels first. One entry per category, + /// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`. + #[serde(default)] + pub category_levels: Vec>, pub series: Vec, /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. #[serde(default)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 037b9099e..3ae40d3bc 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -192,6 +192,7 @@ fn sanitize(d: &ChartData) -> ChartData { ChartData { palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), + category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(), series: d .series .iter() @@ -391,10 +392,20 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos // ---------------------------------------------------------------- axes frame enum CatAxis<'a> { - Labels(&'a [String]), + /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; + /// one entry per category, non-empty where a group starts). + Labels(&'a [String], &'a [Vec]), Numeric(Scale, &'a Fmt), } +/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every +/// non-empty entry (and at the first category). +fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { + let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); + starts.push(n); + starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + struct ValAxis<'a> { scale: Scale, fmt: &'a Fmt, @@ -420,7 +431,11 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), _ => 4.0, }; - let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 }; + let levels = match &cat { + CatAxis::Labels(_, o) => o.len() as f32, + CatAxis::Numeric(..) => 0.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; if !plot.ok() { return plot; } @@ -441,7 +456,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); match cat { - CatAxis::Labels(l) => { + CatAxis::Labels(l, outer) => { let n = l.len().max(1); let cw = plot.w / n as f32; let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); @@ -450,6 +465,22 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis let t = ctx.fit(t, ls, cw * k as f32 - 2.0); ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); } + // Outer levels, one row each below the labels: a group's name centred under its + // categories, and a separator at each group boundary down through the rows. + for (i, level) in outer.iter().enumerate() { + let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); + for (first, end) in level_groups(level, n) { + let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, x1 - x0 - 2.0); + ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if first > 0 { + ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } } CatAxis::Numeric(s, f) => { let ticks = s.ticks(); @@ -469,13 +500,17 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis } plot } else { - let labels: Vec = match cat { - CatAxis::Labels(l) => l.to_vec(), - CatAxis::Numeric(..) => vec![], + let (labels, outer): (Vec, &[Vec]) = match cat { + CatAxis::Labels(l, o) => (l.to_vec(), o), + CatAxis::Numeric(..) => (vec![], &[]), }; let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + // Outer levels stand in columns left of the labels, each as wide as its longest name. + let level_w: Vec = + outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); + let levels_w: f32 = level_w.iter().sum(); let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; - let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 }; + let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; if !plot.ok() { return plot; } @@ -501,6 +536,24 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis let t = ctx.fit(t, ls, lw - 6.0); ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); } + // Outer levels: a group's name centred beside its categories, separators at the + // boundaries running right to the axis. + let mut x1 = plot.x - lw; + for (level, w) in outer.iter().zip(&level_w) { + let x0 = x1 - w; + for (first, end) in level_groups(level, n) { + let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, w - 4.0); + ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); + } + if first > 0 { + ctx.hline(x0, plot.x, y1, AXIS, 0.75); + } + } + ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); + x1 = x0; + } plot } } @@ -938,7 +991,10 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta .fold(0.0, f64::max); let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat)); let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines); + // Outer levels padded to the slots (forecast slots stay in the last group). + let outer: Vec> = + data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); if !plot.ok() { return; } @@ -1236,7 +1292,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let maxc = counts.iter().copied().max().unwrap_or(0) as f64; let ys = nice_scale(0.0, maxc, true); let fc = Fmt::new("General"); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1288,7 +1344,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); let ys = nice_scale(lo, hi, true); let f = Fmt::new(&s.number_format); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1579,7 +1635,7 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1627,7 +1683,7 @@ fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e90..c7a8f0b9c 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -1,7 +1,7 @@ //! Pulls chart data (names, categories, values, formats, colours) out of a workbook. use gridcraft_calc::evaluate; -use gridcraft_core::{CellRef, RangeRef, Value, number_to_text}; +use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text}; use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint}; use gridcraft_numfmt::{NumberFormat, format_value}; @@ -133,12 +133,16 @@ fn value_text(v: &Value) -> String { } } -/// Category labels, formatted with each source cell's number format (dates, currency…). -fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { +/// Category labels, formatted with each source cell's number format (dates, currency…), and +/// the outer levels of a multi-level axis: a category range wider than a column of values (or +/// taller than a row of values) groups its innermost labels, year beside month. Each outer level +/// has one entry per category, non-empty where a group starts, as the cells are. +fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec, Vec>) { let vals = eval_list(wb, sheet, formula); let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula); let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); - vals.iter() + let texts: Vec = vals + .iter() .enumerate() .map(|(i, v)| { if let (Some((s, r)), Value::Number(_)) = (src, v) { @@ -153,7 +157,23 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { } value_text(v) }) - .collect() + .collect(); + let Some((_, r)) = src else { return (texts, vec![]) }; + let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r); + let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) }; + let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize); + if texts.len() != w.saturating_mul(h) { + return (texts, vec![]); // truncated or not a plain block: a flat list + } + let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default(); + let column = |c: usize| (0..h).map(|row| at(row, c)).collect::>(); + let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::>(); + let outer = levels.outer as usize; + if levels.by_columns { + (column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect()) + } else { + (row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect()) + } } /// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. @@ -197,6 +217,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble); let mut series = Vec::new(); let mut categories: Option> = None; + let mut category_levels = Vec::new(); for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() { let values = nums(wb, sheet, &s.values); let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1)); @@ -211,9 +232,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { if categories.is_none() && let Some(f) = &s.categories { - let c = category_texts(wb, sheet, f); + let (c, outer) = category_table(wb, sheet, f, &s.values); if !c.is_empty() { categories = Some(c); + category_levels = outer; } } let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f)); @@ -242,5 +264,5 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE)); - ChartData { categories, series, palette } + ChartData { categories, category_levels, series, palette } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 01f1aac49..52c7e19d4 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData { let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); b.secondary = kind == ChartKind::Combo; ChartData { + category_levels: vec![], palette: vec![], categories: cats.iter().map(|s| s.to_string()).collect(), series: vec![ @@ -363,7 +364,8 @@ fn scatter_with_text_x_values_numbers_the_points() { let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x, None, "{kind:?}"); let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); - let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); + let empty = + render(&c, &ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points"); // Numeric X values are kept. c.series[0].categories = Some("Sheet1!$C$1:$C$3".into()); @@ -456,8 +458,12 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = - ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)], + }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -469,11 +475,13 @@ fn edge_cases_never_panic() { let m = ApproxMeasure; let empty = ChartData::default(); let zeros = ChartData { + category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)], }; let weird = ChartData { + category_levels: vec![], palette: vec![], categories: vec![String::new(); 3], series: vec![SeriesData { @@ -505,7 +513,12 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); let mut c = chart(ChartKind::Line); c.data_labels = true; - let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)], + }; let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); let max_line = prims .iter() @@ -530,7 +543,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = smooth; - let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: cats.clone(), series: vec![s] }; render(&chart(ChartKind::Line), &d, 600.0, 400.0, m) .iter() .filter_map(|p| match p { @@ -547,7 +560,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { // Rendering either way must stay finite and inside the canvas. let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; - let d = ChartData { palette: vec![], categories: cats, series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: cats, series: vec![s] }; assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0); } @@ -557,7 +570,7 @@ fn smooth_line_splits_at_gaps() { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; s.values[1] = None; // gap between two data points - let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m); // Two separate runs (before/after the gap) → at least two line primitives. let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count(); @@ -634,7 +647,12 @@ fn fuzz_random_data_and_sizes() { }) .collect(); let ncat = (r.next() % 50) as usize; - let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), + series, + }; let mut c = chart(kind); c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; c.data_labels = r.next().is_multiple_of(2); @@ -809,7 +827,8 @@ fn line_chart_draws_trendline_forecast_and_labels() { let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; - let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let d = + ChartData { category_levels: vec![], palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; let mut c = chart(ChartKind::Line); c.legend = LegendPos::None; // keeps legend swatches out of the line counts let prims = render(&c, &d, 600.0, 400.0, &m); @@ -833,7 +852,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { assert!(lines_of(&pie, tc).is_empty()); // One point: nothing to fit, nothing drawn. s.values.truncate(1); - let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] }; + let one = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into()], series: vec![s] }; assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); } @@ -853,7 +872,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, ]; - let d = ChartData { palette: vec![], categories: vec![], series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![s] }; let mut c = chart(ChartKind::Scatter); c.legend = LegendPos::None; let prims = render(&c, &d, 600.0, 400.0, &m); @@ -868,6 +887,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { #[test] fn treemap_and_histogram_shapes() { let d = ChartData { + category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], @@ -889,7 +909,12 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (0..100).map(|i| i as f64).collect(); - let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)], + }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); @@ -898,7 +923,12 @@ fn treemap_and_histogram_shapes() { #[test] fn histogram_boundary_values_match_interval_labels() { let heights = |vals: &[f64]| -> Vec { - let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)], + }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect() }; @@ -967,6 +997,7 @@ fn box_and_stock_charts_honour_the_gridlines_flag() { #[test] fn funnel_value_labels_follow_the_data_labels_flag() { let data = ChartData { + category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into()], series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], @@ -1057,3 +1088,77 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { assert!(first_label > last_poly, "{kind:?}: label drawn under a later area"); } } + +#[test] +fn two_level_categories_resolve_and_draw() { + let years = ["2023", "", "", "2024", "", ""]; + let months = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"]; + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Sales".into(); + for (i, (y, m)) in years.iter().zip(months).enumerate() { + // Year beside month beside the values, points down the rows… + let r = i as u32 + 1; + if !y.is_empty() { + s.set_value(CellRef::new(r, 0), Value::from(*y)); + s.set_value(CellRef::new(10, i as u32), Value::from(*y)); + } + s.set_value(CellRef::new(r, 1), Value::from(m)); + s.set_value(CellRef::new(r, 2), Value::Number(10.0 * (i as f64 + 1.0))); + // …and the same table in rows, points along the columns. + s.set_value(CellRef::new(11, i as u32), Value::from(m)); + s.set_value(CellRef::new(12, i as u32), Value::Number(10.0 * (i as f64 + 1.0))); + } + } + let mut c = chart(ChartKind::Line); + c.series = vec![Series { categories: Some("Sales!$A$2:$B$7".into()), values: "Sales!$C$2:$C$7".into(), ..Default::default() }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, months); + assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]); + + let texts = |prims: &[Prim]| -> Vec<(String, f32, f32)> { + prims.iter().filter_map(|p| if let Prim::Text { text, x, y, .. } = p { Some((text.clone(), *x, *y)) } else { None }).collect() + }; + let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure); + assert_sane(&prims, 600.0, 300.0); + let t = texts(&prims); + let at = |t: &[(String, f32, f32)], s: &str| { + t.iter().find(|(x, _, _)| x == s).map(|(_, x, y)| (*x, *y)).unwrap_or_else(|| panic!("no {s:?} in {t:?}")) + }; + // Six evenly spaced month labels across the plot, not twelve slots for a two-column range. + let xs: Vec = months.iter().map(|m| at(&t, m).0).collect(); + let step = xs[1] - xs[0]; + assert!(step > 40.0 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "{xs:?}"); + // The years sit in a row below the months, each under its own group. + let (x23, y23) = at(&t, "2023"); + let (x24, y24) = at(&t, "2024"); + assert!(y23 > at(&t, "Oct").1 && (y23 - y24).abs() < 0.01, "{t:?}"); + assert!(x23 < at(&t, "Dec").0 && x24 > at(&t, "Dec").0 && x24 < at(&t, "Mar").0, "{t:?}"); + + // Bars: the levels stand in a column left of the labels. + c.kind = ChartKind::BarClustered; + let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure); + assert_sane(&prims, 600.0, 300.0); + let t = texts(&prims); + let (x23, y23) = at(&t, "2023"); + let (x_oct, y_oct) = at(&t, "Oct"); + // Categories run up the axis: the group's name sits left of its labels, between Oct and Dec. + assert!(x23 < x_oct && y23 < y_oct && y23 > at(&t, "Dec").1, "{t:?}"); + let (x24, y24) = at(&t, "2024"); + assert!((x23 - x24).abs() < 0.01 && y24 < y23, "same column, 2024 above: {t:?}"); + + // A row of values: the levels are rows. + c.series[0].categories = Some("Sales!$A$11:$F$12".into()); + c.series[0].values = "Sales!$A$13:$F$13".into(); + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, months); + assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]); + + // One column of categories: a plain axis, as before. + c.series[0].categories = Some("Sales!$B$2:$B$7".into()); + c.series[0].values = "Sales!$C$2:$C$7".into(); + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, months); + assert!(d.category_levels.is_empty()); +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 2bdfe797a..5302527b2 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -293,3 +293,52 @@ mod tests { assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3)); } } + +/// The shape of a chart's category range beside its values: a plain list, or a multi-level +/// axis (Excel: a category range two or more columns wide beside a column of values is a +/// two-level axis, year beside month; one two or more rows tall above a row of values likewise). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CategoryLevels { + /// The levels are columns and the points run down the rows (else the levels are rows and the + /// points run along the columns). + pub by_columns: bool, + /// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a + /// two-level axis. At most 3 are kept. + pub outer: u32, +} + +/// How `categories` lays its labels out beside `values` (`None` when the values aren't a +/// reference: the categories' own shape decides). `None` for a plain list of labels. +pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option { + if categories.is_full_rows() || categories.is_full_cols() { + return None; + } + let (w, h) = (categories.width(), categories.height()); + let vertical = match values { + Some(v) => v.height() >= v.width(), + None => h >= w, + }; + let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) }; + (outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) }) +} + +#[cfg(test)] +mod category_level_tests { + use super::*; + + #[test] + fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() { + let r = |s: &str| RangeRef::parse(s).unwrap(); + assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 })); + assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped"); + assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list"); + // A row of values: levels are rows. + assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None); + // Literal values: the categories' own shape decides. + assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..9e04e8b27 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -8,6 +8,6 @@ pub mod date; pub mod parse; pub mod value; -pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; +pub use addr::{CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col}; pub use date::DateSystem; pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare}; diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..182345799 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -3,7 +3,7 @@ use std::fmt::Write as _; -use gridcraft_core::Value; +use gridcraft_core::{RangeRef, Value, category_levels}; use gridcraft_formula::{Expr, SheetSel}; use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; @@ -281,8 +281,8 @@ fn group_for(k: ChartKind) -> G { const MAX_CACHE: u64 = 100_000; -/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major. -pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option> { +/// The sheet and range a same-workbook reference (`Sheet1!$B$2:$B$9`) names. +fn ref_target(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<(usize, RangeRef)> { let e = gridcraft_formula::parse(f).ok()?; let Expr::Ref(r) = e else { return None }; let si = match &r.sheet { @@ -290,8 +290,13 @@ pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option wb.sheet_index(n)?, SheetSel::Span(..) => return None, }; + Some((si, r.range())) +} + +/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major. +pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option> { + let (si, mut range) = ref_target(wb, sheet_idx, f)?; let sheet = wb.sheet(si)?; - let mut range = r.range(); // Whole columns/rows: limit to the used range. if let Some(u) = sheet.used_range() { range = range.intersection(&u)?; @@ -397,6 +402,47 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { s } +/// `` of a series: a string reference, or a multi-level one (`multiLvlStrRef`) when the +/// category range has more columns than a column of values (or more rows than a row of values: +/// year beside month). Its cache lists the innermost level first, then each outer level with +/// only the cells that start a group, as Excel writes it. +fn cat_data(f: &str, values: &str, wb: &Workbook, si: usize) -> String { + multi_level_cat(f.trim(), values, wb, si).unwrap_or_else(|| str_data("cat", f, wb, si)) +} + +fn multi_level_cat(f: &str, values: &str, wb: &Workbook, si: usize) -> Option { + let (sheet, range) = ref_target(wb, si, f)?; + let value_range = ref_target(wb, si, values).map(|(_, r)| r); + let levels = category_levels(&range, value_range.as_ref())?; + if range.count() > MAX_CACHE { + return None; + } + let sheet = wb.sheet(sheet)?; + let (w, h) = (range.width() as usize, range.height() as usize); + let text = |row: usize, col: usize| -> String { + let v = range.start.offset(row as i64, col as i64).map(|c| sheet.value(c)); + v.filter(|v| !v.is_empty()).map(|v| v.display()).unwrap_or_default() + }; + // The innermost level (nearest the values), then the outer ones. + let (n, lvls): (usize, Vec>) = if levels.by_columns { + (h, (0..=levels.outer as usize).filter_map(|k| (w - 1).checked_sub(k)).map(|c| (0..h).map(|r| text(r, c)).collect()).collect()) + } else { + (w, (0..=levels.outer as usize).filter_map(|k| (h - 1).checked_sub(k)).map(|r| (0..w).map(|c| text(r, c)).collect()).collect()) + }; + let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + for lvl in &lvls { + s.push_str(""); + for (i, v) in lvl.iter().enumerate() { + if !v.is_empty() { + let _ = write!(s, "{}", esc(v)); + } + } + s.push_str(""); + } + s.push_str(""); + Some(s) +} + fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); if let Some(n) = &s.name { @@ -453,7 +499,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String } _ => { if let Some(c) = &s.categories { - x.push_str(&str_data("cat", c, wb, si)); + x.push_str(&cat_data(c, &s.values, wb, si)); } x.push_str(&num_data("val", Some(&s.values), wb, si)); if matches!(g, G::Line { .. }) { diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..d4234a832 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -8,6 +8,7 @@ mod fixtures; mod ink_roundtrip; mod literal_category_roundtrip; mod malformed; +mod multilevel_category_roundtrip; mod ods_tests; mod passthrough_tests; mod pivot_tests; diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs new file mode 100644 index 000000000..556a8917a --- /dev/null +++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs @@ -0,0 +1,109 @@ +//! A two-column category range (year beside month) is written as a multi-level string reference +//! with Excel's level caches, and read back as the same reference. + +use std::io::Read as _; +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Anchor, Chart, ChartKind, LegendPos, Series, Sheet, Workbook}; + +use crate::{read_xlsx, write_xlsx}; + +const YEARS: [&str; 6] = ["2023", "", "", "2024", "", ""]; +const MONTHS: [&str; 6] = ["окт", "ноя", "дек", "янв", "фев", "мар"]; + +fn workbook(categories: &str, values: &str) -> Workbook { + let mut s = Sheet::new("Data"); + for (i, (y, m)) in YEARS.iter().zip(MONTHS).enumerate() { + let r = i as u32 + 1; + if !y.is_empty() { + s.set_value(CellRef::new(r, 0), Value::from(*y)); + } + s.set_value(CellRef::new(r, 1), Value::from(m)); + s.set_value(CellRef::new(r, 2), Value::Number(i as f64 + 1.0)); + } + s.charts.push(Chart { + id: 1, + kind: ChartKind::Line, + anchor: Anchor { cell: CellRef::new(0, 4), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![Series { + name: None, + categories: Some(categories.into()), + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }], + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() } +} + +fn chart_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let mut s = String::new(); + z.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +fn cat(xml: &str) -> &str { + let a = xml.find("").expect("a "); + let b = xml.find("").expect("a "); + &xml[a..b] +} + +#[test] +fn two_column_categories_are_written_as_levels_and_read_back() { + let bytes = write_xlsx(&workbook("Data!$A$2:$B$7", "Data!$C$2:$C$7")).unwrap(); + let xml = chart_xml(&bytes); + let c = cat(&xml); + assert!(c.starts_with("Data!$A$2:$B$7"), "{c}"); + let lvls: Vec<&str> = c.split("").skip(1).collect(); + assert_eq!(lvls.len(), 2, "{c}"); + // The innermost level first, every month; then the years, only where a group starts. + assert!(lvls[0].contains("окт") && lvls[0].contains("мар"), "{c}"); + assert!(lvls[1].starts_with("20232024"), "{c}"); + assert!(!c.contains("")); + + let (back, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.sheets[0].charts[0].series[0].categories.as_deref(), Some("Data!$A$2:$B$7")); + assert_eq!(cat(&chart_xml(&write_xlsx(&back).unwrap())), c, "stable across saves"); +} + +#[test] +fn a_row_of_values_makes_the_levels_rows() { + let mut wb = workbook("Data!$A$9:$F$10", "Data!$A$11:$F$11"); + let s = Arc::make_mut(&mut wb.sheets[0]); + for (i, (y, m)) in YEARS.iter().zip(MONTHS).enumerate() { + if !y.is_empty() { + s.set_value(CellRef::new(8, i as u32), Value::from(*y)); + } + s.set_value(CellRef::new(9, i as u32), Value::from(m)); + s.set_value(CellRef::new(10, i as u32), Value::Number(i as f64)); + } + let xml = chart_xml(&write_xlsx(&wb).unwrap()); + let c = cat(&xml); + assert!(c.contains("Data!$A$9:$F$10"), "{c}"); + let lvls: Vec<&str> = c.split("").skip(1).collect(); + assert_eq!(lvls.len(), 2); + assert!(lvls[0].contains("окт") && lvls[1].contains("2024"), "{c}"); +} + +#[test] +fn a_single_column_of_categories_stays_a_plain_reference() { + let xml = chart_xml(&write_xlsx(&workbook("Data!$B$2:$B$7", "Data!$C$2:$C$7")).unwrap()); + assert!(xml.contains("Data!$B$2:$B$7"), "{xml}"); + assert!(!xml.contains("multiLvlStrRef")); +} diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 9058b0935..40f6787a9 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (multi-level category axes, year beside month, are read, drawn and written back; previously trivial: chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -41,6 +41,7 @@ open PRs #158, #161, and issue #190. | Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag | | Gridlines | major/minor per axis, formatting | one on/off flag | | Axis options | min/max/units, log scale, reverse order, crossing point, display units, date axis, number format | **missing** (auto only) | +| Multi-level category axis (a category range two or more columns wide beside the values: year beside month) | the outer levels grouped under the labels with separators | read (`multiLvlStrRef`), drawn (rows under the labels; columns beside them in bar charts; up to three outer levels), written back with Excel's level caches | | Secondary axis | any series | line series in combo charts | | Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | all six types, several per series, name, forward/backward forecast, set intercept, equation and R² label, line colour/width/dash; read, written and drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `trendline` adds/removes them. No Format Trendline pane, label position/number format not kept | | Error bars | fixed, %, standard deviation/error, custom | **missing** | @@ -74,12 +75,13 @@ back. 4. Error bars; Format Trendline pane (trendlines landed): 4–6 h. 5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h. 6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h. -7. Chart sheets, templates, PivotChart field buttons and multi-level categories: 6–10 h. Filled map: owner decision on boundary data. +7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches | | 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) | | 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` | | 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) | diff --git a/docs/gaps.md b/docs/gaps.md index 03e8672c5..65287115a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -46,7 +46,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap, sunburst), axis scaling, label options, error bars, per-point formatting. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles - (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. + (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back. - **Impact:** charts are in most business workbooks. - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) @@ -168,6 +168,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | §4: multi-level category axes landed | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) | | 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | From 5effeea8fc40378b0baaab8092d14b062eaa69e9 Mon Sep 17 00:00:00 2001 From: mbielski1337 Date: Tue, 13 Oct 2026 17:01:15 +0200 Subject: [PATCH 041/446] i18n: add Polish translation table --- crates/ui-egui/src/i18n/pl.rs | 983 ++++++++++++++++++++++++++++++++++ 1 file changed, 983 insertions(+) create mode 100644 crates/ui-egui/src/i18n/pl.rs diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs new file mode 100644 index 000000000..d351b8597 --- /dev/null +++ b/crates/ui-egui/src/i18n/pl.rs @@ -0,0 +1,983 @@ +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Wklej", + "Paste Values" => "Wklej wartości", + "Paste Formulas" => "Wklej formuły", + "Paste Formatting" => "Wklej formatowanie", + "Transpose" => "Transpozycja", + "Paste Link" => "Wklej łącze", + "Paste Special…" => "Wklej specjalnie…", + "Insert Copied Cells" => "Wstaw skopiowane komórki", + "Insert Copied Cells…" => "Wstaw skopiowane komórki…", + "Insert Cut Cells" => "Wstaw wycięte komórki", + "Insert Paste" => "Wklej ze wstawieniem", + "Cut (⌘X)" => "Wytnij (⌘X)", + "Copy (⌘C)" => "Kopiuj (⌘C)", + "Format Painter (double-click to keep it on)" => "Malarz formatów (kliknij dwukrotnie, aby pozostał włączony)", + "Bold (⌘B)" => "Pogrubienie (⌘B)", + "Italic (⌘I)" => "Kursywa (⌘I)", + "Underline (⌘U)" => "Podkreślenie (⌘U)", + "Double Underline" => "Podwójne podkreślenie", + "Strikethrough" => "Przekreślenie", + "Underline" => "Podkreślenie", + "Increase Font Size" => "Zwiększ rozmiar czcionki", + "Decrease Font Size" => "Zmniejsz rozmiar czcionki", + "Wrap Text" => "Zawijaj tekst", + "Merge & Center" => "Scal i wyśrodkuj", + "Merge Cells" => "Scal komórki", + "Merge Across" => "Scal wszystkie", + "Unmerge Cells" => "Rozdziel komórki", + "Fill Color" => "Kolor wypełnienia", + "Font Color" => "Kolor czcionki", + "Borders" => "Obramowania", + "Top\nAlign" => "Wyrównaj\ndo góry", + "Middle\nAlign" => "Wyrównaj\ndo środka", + "Bottom\nAlign" => "Wyrównaj\ndo dołu", + "Left" => "Do lewej", + "Right" => "Do prawej", + "Center" => "Do środka", + "Horizontal" => "Poziomo", + "Vertical Text" => "Tekst pionowy", + "Decrease Indent" => "Zmniejsz wcięcie", + "Increase Indent" => "Zwiększ wcięcie", + "Orientation" => "Orientacja", + "Angle Counterclockwise" => "Kąt przeciwnie do ruchu wskazówek zegara", + "Angle Clockwise" => "Kąt zgodnie z ruchem wskazówek zegara", + "Rotate Text Up" => "Obróć tekst w górę", + "Rotate Text Down" => "Obróć tekst w dół", + "Accounting Number Format" => "Format liczb księgowych", + "Percent Style" => "Zapis procentowy", + "Comma Style" => "Zapis dziesiętny", + "Increase Decimal" => "Zwiększ dziesiętne", + "Decrease Decimal" => "Zmniejsz dziesiętne", + "More Accounting Formats…" => "Więcej formatów księgowych…", + "Conditional\nFormatting" => "Formatowanie\nwarunkowe", + "Conditional Formatting" => "Formatowanie warunkowe", + "Format\nas Table" => "Formatuj\njako tabelę", + "Cell\nStyles" => "Style\nkomórki", + "Format Cells…" => "Formatuj komórki…", + "Format Cell Alignment…" => "Formatuj wyrównanie komórek…", + "Format\nPane" => "Okienko\nformatowania", + "Format" => "Formatuj", + "Insert" => "Wstaw", + "Insert Cells…" => "Wstaw komórki…", + "Delete" => "Usuń", + "Delete Cells…" => "Usuń komórki…", + "Insert Sheet Rows" => "Wstaw wiersze arkusza", + "Insert Sheet Columns" => "Wstaw kolumny arkusza", + "Delete Sheet Rows" => "Usuń wiersze arkusza", + "Delete Sheet Columns" => "Usuń kolumny arkusza", + "Row Height…" => "Wysokość wiersza…", + "AutoFit Row Height" => "Autodopasowanie wysokości wiersza", + "Column Width…" => "Szerokość kolumny…", + "AutoFit Column Width" => "Autodopasowanie szerokości kolumny", + "Default Width…" => "Szerokość domyślna…", + "Hide Rows" => "Ukryj wiersze", + "Unhide Rows" => "Odkryj wiersze", + "Hide Columns" => "Ukryj kolumny", + "Unhide Columns" => "Odkryj kolumny", + "Lock Cell" => "Zablokuj komórkę", + "Clear" => "Wyczyść", + "Clear All" => "Wyczyść wszystko", + "Clear Formats" => "Wyczyść formaty", + "Clear Contents" => "Wyczyść zawartość", + "Clear Comments and Notes" => "Wyczyść komentarze i notatki", + "Clear Hyperlinks" => "Wyczyść hiperlinki", + "AutoSum" => "Autosumowanie", + "AutoSum (⌘⇧T)" => "Autosumowanie (⌘⇧T)", + "Flash\nFill" => "Wypełnianie\nbłyskawiczne", + "Flash Fill" => "Wypełnianie błyskawiczne", + "Fill" => "Wypełnij", + "Clear Print Area" => "Wyczyść obszar wydruku", + "Find &\nSelect" => "Znajdź i\nzaznacz", + "Find…" => "Znajdź…", + "Replace…" => "Zamień…", + "Go To…" => "Przejdź do…", + "Go To Special…" => "Przejdź do (specjalnie)…", + "PivotTable" => "Tabela\nprzestawna", + "Recommended\nCharts" => "Polecane\nwykresy", + "Table" => "Tabela", + "Pictures" => "Obrazy", + "Shapes" => "Kształty", + "Icons" => "Ikony", + "Text\nBox" => "Pole\ntekstowe", + "Comment" => "Komentarz", + "New\nComment" => "Nowy\nkomentarz", + "New Note" => "Nowa notatka", + "Show/Hide Note" => "Pokaż/ukryj notatkę", + "Header &\nFooter" => "Nagłówek\ni stopka", + "Text to\nColumns" => "Tekst jako\nkolumny", + "Link" => "Łącze", + "Symbol" => "Symbol", + "Sparklines" => "Wykresy przebiegu w czasie", + "Checkbox" => "Pole wyboru", + "Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach", + "Header & Footer" => "Nagłówek i stopka", + "Draw with ink" => "Rysuj piórem", + "Eraser" => "Gumka", + "Ink to\nShape" => "Pismo odręczne\nna kształt", + "Select\nObjects" => "Zaznacz\nobiekty", + "Themes" => "Motywy", + "Margins" => "Marginesy", + "Left Margin" => "Lewy margines", + "Right Margin" => "Prawy margines", + "Top Margin" => "Górny margines", + "Bottom Margin" => "Dolny margines", + "Header Margin" => "Margines nagłówka", + "Footer Margin" => "Margines stopki", + "Size" => "Rozmiar", + "Print\nArea" => "Obszar\nwydruku", + "Set Print Area" => "Ustaw obszar wydruku", + "Breaks" => "Podziały", + "Insert Page Break" => "Wstaw podział strony", + "Remove Page Break" => "Usuń podział strony", + "Reset All Page Breaks" => "Resetuj wszystkie podziały stron", + "Print\nTitles" => "Tytuły\nwydruku", + "Sheet\nRight-to-Left" => "Arkusz od prawej\ndo lewej", + "Sheet Right-to-Left" => "Arkusz od prawej do lewej", + "Narrow" => "Wąskie", + "Wide" => "Szerokie", + "Normal" => "Normalne", + "Portrait" => "Pionowa", + "Landscape" => "Pozioma", + "Custom Margins…" => "Marginesy niestandardowe…", + "Insert\nFunction" => "Wstaw\nfunkcję", + "Name\nManager" => "Menedżer\nnazw", + "Define Name" => "Definiuj nazwę", + "Use in Formula" => "Użyj w formule", + "Create from Selection" => "Utwórz z zaznaczenia", + "Trace Precedents" => "Śledź poprzedniki", + "Trace Dependents" => "Śledź zależności", + "Remove Arrows" => "Usuń strzałki", + "Show Formulas" => "Pokaż formuły", + "Error Checking" => "Sprawdzanie błędów", + "Evaluate Formula" => "Szacuj formułę", + "Watch Window" => "Okno czujki", + "Calculation\nOptions" => "Opcje\nobliczania", + "Calculate Now" => "Oblicz teraz", + "Calculate Sheet" => "Oblicz arkusz", + "Automatic" => "Automatycznie", + "Automatic Except for Data Tables" => "Automatycznie z wyjątkiem tabel danych", + "Manual" => "Ręcznie", + "Sort &\nFilter" => "Sortuj i\nfiltruj", + "Sort" => "Sortuj", + "Sort A to Z" => "Sortuj od A do Z", + "Sort Z to A" => "Sortuj od Z do A", + "Custom Sort…" => "Sortowanie niestandardowe…", + "Filter" => "Filtruj", + "Reapply" => "Zastosuj ponownie", + "Get Data\n(Text/CSV)" => "Pobierz dane\n(tekst/CSV)", + "Remove\nDuplicates" => "Usuń\nduplikaty", + "Data\nValidation" => "Poprawność\ndanych", + "Data Validation" => "Poprawność danych", + "Data Validation…" => "Poprawność danych…", + "Circle Invalid Data" => "Zakreśl nieprawidłowe dane", + "Clear Validation" => "Wyczyść poprawność danych", + "Group" => "Grupuj", + "Ungroup" => "Rozgrupuj", + "Show Detail" => "Pokaż szczegóły", + "Hide Detail" => "Ukryj szczegóły", + "Subtotal" => "Suma częściowa", + "Data\nAnalysis" => "Analiza\ndanych", + "Data Analysis" => "Analiza danych", + "Solver" => "Solver", + "What-If\nAnalysis" => "Analiza\nwarunkowa", + "Data Table…" => "Tabela danych…", + "Scenario Manager…" => "Menedżer scenariuszy…", + "Goal Seek…" => "Szukaj wyniku…", + "Forecast\nSheet" => "Arkusz\nprognozy", + "Spelling" => "Pisownia", + "Check\nAccessibility" => "Sprawdź ułatwienia\ndostępu", + "Threaded Comments" => "Komentarze wątkowe", + "Show\nComments" => "Pokaż\nkomentarze", + "Previous" => "Poprzedni", + "Next" => "Następny", + "Previous Comment" => "Poprzedni komentarz", + "Next Comment" => "Następny komentarz", + "Show All Notes" => "Pokaż wszystkie notatki", + "Convert to Comments" => "Konwertuj na komentarze", + "Protect\nWorkbook" => "Chroń\nskoroszyt", + "Allow Edit\nRanges" => "Zezwalaj na\nedycję zakresów", + "Allow Edit Ranges" => "Zezwalaj na edycję zakresów", + "Unprotect\nSheet" => "Nie chroń\narkusza", + "Protect Sheet…" => "Chroń arkusz…", + "Comments" => "Komentarze", + "Notes" => "Notatki", + "Page Break\nPreview" => "Podgląd\npodziału stron", + "Page\nLayout" => "Układ\nstrony", + "Formula Bar" => "Pasek formuły", + "Gridlines" => "Linie siatki", + "Headings" => "Nagłówki", + "Zoom" => "Powiększenie", + "100%" => "100%", + "Zoom to\nSelection" => "Powiększ do\nzaznaczenia", + "Freeze\nPanes" => "Zablokuj\nokienka", + "Freeze Panes" => "Zablokuj okienka", + "Freeze Top Row" => "Zablokuj górny wiersz", + "Freeze First Column" => "Zablokuj pierwszą kolumnę", + "Unfreeze Panes" => "Odblokuj okienka", + "Dark Mode" => "Tryb ciemny", + "Interface language" => "Język interfejsu", + "Interface size" => "Rozmiar interfejsu", + "Auto" => "Automatyczny", + "Drag to freeze columns" => "Przeciągnij, aby zablokować kolumny", + "Drag to freeze rows" => "Przeciągnij, aby zablokować wiersze", + "Command\nPalette" => "Paleta\npoleceń", + "Agent\nControl" => "Sterowanie\nagentem", + "Action\nJournal" => "Dziennik\nakcji", + "About\nGridCraft" => "O programie\nGridCraft", + "Share" => "Udostępnij", + "Add Chart\nElement" => "Dodaj element\nwykresu", + "Change\nChart Type" => "Zmień\ntyp wykresu", + "Switch\nRow/Column" => "Przełącz\nwiersz/kolumnę", + "Select\nData" => "Zaznacz\ndane", + "Data Labels: None" => "Etykiety danych: brak", + "Data Labels: Show" => "Etykiety danych: pokaż", + "Legend: None" => "Legenda: brak", + "Legend: Bottom" => "Legenda: u dołu", + "Legend: Top" => "Legenda: u góry", + "Legend: Right" => "Legenda: z prawej", + "Gridlines: None" => "Linie siatki: brak", + "Gridlines: Show" => "Linie siatki: pokaż", + "Chart\nTitle" => "Tytuł\nwykresu", + "Delete\nChart" => "Usuń\nwykres", + "Convert\nto Range" => "Konwertuj\nna zakres", + "Table\nStyles" => "Style\ntabeli", + "Top 10 Items" => "10 pierwszych elementów", + "Top 10%" => "Pierwsze 10%", + "Bottom 10 Items" => "10 ostatnich elementów", + "Bottom 10%" => "Ostatnie 10%", + "Above Average" => "Powyżej średniej", + "Below Average" => "Poniżej średniej", + "Clear Rules from Entire Sheet" => "Wyczyść reguły z całego arkusza", + "Clear Rules from Selected Cells" => "Wyczyść reguły z zaznaczonych komórek", + "Constants" => "Stałe", + "Data Bars" => "Paski danych", + "Color Scales" => "Skale kolorów", + "Icon Sets" => "Zestawy ikon", + "Home" => "Narzędzia główne", + "Draw" => "Rysowanie", + "Page Layout" => "Układ strony", + "Formulas" => "Formuły", + "Data" => "Dane", + "Review" => "Recenzja", + "View" => "Widok", + "Automate" => "Automatyzacja", + "Table Design" => "Projekt tabeli", + "Chart Design" => "Projekt wykresu", + "Ready" => "Gotowy", + "Enter" => "Wprowadź", + "Edit" => "Edytuj", + "Point" => "Wskaż", + "Calculate" => "Oblicz", + "Select destination and press Enter or choose Paste" => "Zaznacz miejsce docelowe i naciśnij Enter lub wybierz polecenie Wklej", + "General" => "Ogólne", + "Number" => "Liczbowe", + "Currency" => "Walutowe", + "Accounting" => "Księgowe", + "Date" => "Data", + "Time" => "Czas", + "Percentage" => "Procentowe", + "Fraction" => "Ułamkowe", + "Scientific" => "Naukowe", + "Text" => "Tekstowe", + "Special" => "Specjalne", + "Custom" => "Niestandardowe", + "Short Date" => "Data krótka", + "Long Date" => "Data długa", + "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", + "Page Layout|Orientation" => "Orientacja", + "Display theme" => "Motyw wyświetlania", + "Animations" => "Animacje", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Płynnie przesuwaj ramkę zaznaczenia do komórki, do której przechodzisz, zamiast przeskakiwać" + } + "System" => "Systemowy", + "Light" => "Jasny", + "Dark" => "Ciemny", + "Sheet Options|View" => "Wyświetl", + "Print" => "Drukuj", + "Click to add data" => "Kliknij, aby dodać dane", + "Add header" => "Dodaj nagłówek", + "Add footer" => "Dodaj stopkę", + "(none)" => "(brak)", + "Header" => "Nagłówek", + "Footer" => "Stopka", + "Page\nNumber" => "Numer\nstrony", + "Number\nof Pages" => "Liczba\nstron", + "Current\nDate" => "Bieżąca\ndata", + "Current\nTime" => "Bieżąca\ngodzina", + "File\nPath" => "Ścieżka\npliku", + "File\nName" => "Nazwa\npliku", + "Sheet\nName" => "Nazwa\narkusza", + "Go to\nHeader" => "Przejdź do\nnagłówka", + "Go to\nFooter" => "Przejdź do\nstopki", + "Page Number" => "Numer strony", + "Number of Pages" => "Liczba stron", + "Current Date" => "Bieżąca data", + "Current Time" => "Bieżąca godzina", + "File Path" => "Ścieżka pliku", + "File Name" => "Nazwa pliku", + "Sheet Name" => "Nazwa arkusza", + "Go to Header" => "Przejdź do nagłówka", + "Different First Page" => "Inna pierwsza strona", + "Different Odd & Even Pages" => "Inne strony nieparzyste i parzyste", + "Go to Footer" => "Przejdź do stopki", + "Ruler" => "Linijka", + "File" => "Plik", + "Back" => "Wstecz", + "New" => "Nowy", + "Open" => "Otwórz", + "Info" => "Informacje", + "Save" => "Zapisz", + "Save As" => "Zapisz jako", + "Export" => "Eksportuj", + "Close" => "Zamknij", + "Options" => "Opcje", + "Blank workbook" => "Pusty skoroszyt", + "Recent" => "Ostatnie", + "No recent workbooks yet." => "Nie masz jeszcze ostatnio używanych skoroszytów.", + "More workbooks" => "Więcej skoroszytów", + "Modified" => "Zmodyfikowano", + "Not available" => "Niedostępne", + "Just now" => "Przed chwilą", + "{n} min ago" => "{n} min temu", + "{n} h ago" => "{n} godz. temu", + "{n} days ago" => "{n} dni temu", + "{n} weeks ago" => "{n} tyg. temu", + "{n} months ago" => "{n} mies. temu", + "{n} years ago" => "{n} l. temu", + "Browse…" => "Przeglądaj…", + "Not saved yet" => "Jeszcze niezapisany", + "Location" => "Lokalizacja", + "Sheets" => "Arkusze", + "Protect Workbook" => "Chroń skoroszyt", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Struktura arkuszy jest zablokowana: nie można dodawać, przenosić, usuwać arkuszy ani zmieniać ich nazw." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Zablokuj strukturę arkuszy, żeby nie dało się dodawać, przenosić, usuwać arkuszy ani zmieniać ich nazw." + } + "Properties" => "Właściwości", + "Title" => "Tytuł", + "Subject" => "Temat", + "Author" => "Autor", + "Company" => "Firma", + "Keywords" => "Słowa kluczowe", + "Description" => "Opis", + "No workbook is open." => "Żaden skoroszyt nie jest otwarty.", + "Save as type" => "Zapisz jako typ", + "Excel Workbook (*.xlsx)" => "Skoroszyt programu Excel (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (rozdzielany przecinkami) (*.csv)", + "Web Page (*.html)" => "Strona sieci Web (*.html)", + "PDF (*.pdf)" => "PDF (*.pdf)", + "Download" => "Pobierz", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "Plik CSV, strona sieci Web i PDF to tylko kopie: skoroszyt zostaje w swoim pliku." + } + "Save the workbook under another name or in another folder." => "Zapisz skoroszyt pod inną nazwą lub w innym folderze.", + "Print Active Sheets" => "Drukuj aktywne arkusze", + "Print Entire Workbook" => "Drukuj cały skoroszyt", + "Fit Sheet on One Page" => "Dopasuj arkusz do jednej strony", + "Page Setup…" => "Ustawienia strony…", + "Page {page} of {pages}" => "Strona {page} z {pages}", + "Nothing to print." => "Nie ma nic do wydrukowania.", + "Previous page" => "Poprzednia strona", + "Next page" => "Następna strona", + "Create PDF" => "Utwórz plik PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Zapisz arkusz jako dokument PDF z zachowaniem układu strony.", + "Change File Type" => "Zmień typ pliku", + "CSV (Comma delimited)" => "CSV (rozdzielany przecinkami)", + "Save the active sheet as comma-separated text." => "Zapisz aktywny arkusz jako tekst rozdzielany przecinkami.", + "Web Page (.html)" => "Strona sieci Web (.html)", + "Save the workbook as a web page." => "Zapisz skoroszyt jako stronę sieci Web.", + "Options|General" => "Ogólne", + "Options|Save" => "Zapisywanie", + "Show formula bar" => "Pokaż pasek formuły", + "Enable AutoComplete for cell values" => "Włącz Autouzupełnianie dla wartości komórek", + "Save .xlsx workbooks automatically after every change" => "Automatycznie zapisuj skoroszyty .xlsx po każdej zmianie", + "Workbook calculation" => "Obliczenia w skoroszycie", + "Enable iterative calculation" => "Włącz obliczenia iteracyjne", + "Calculation settings are saved with the workbook." => "Ustawienia obliczeń są zapisywane razem ze skoroszytem.", + "OK" => "OK", + "Cancel" => "Anuluj", + "Don't Save" => "Nie zapisuj", + "Open…" => "Otwórz…", + "Show password" => "Pokaż hasło", + "Start a new workbook" => "Utwórz nowy skoroszyt", + "Household Budget" => "Budżet domowy", + "Quarterly Sales Dashboard" => "Pulpit sprzedaży kwartalnej", + "Gradebook" => "Dziennik ocen", + "this workbook" => "ten skoroszyt", + "This workbook" => "Ten skoroszyt", + "Do you want to save the changes you made to {title}?" => "Czy chcesz zapisać zmiany wprowadzone w pliku {title}?", + "Save a copy as .{format}" => "Zapisz kopię jako .{format}", + "Select a chart first." => "Najpierw zaznacz wykres.", + "Text Box" => "Pole tekstowe", + "Select Data" => "Zaznacz dane", + "Row Height" => "Wysokość wiersza", + "Column Width" => "Szerokość kolumny", + "Standard Width" => "Szerokość standardowa", + "Rename Sheet" => "Zmień nazwę arkusza", + "Move or Copy" => "Przenoszenie lub kopiowanie", + "Protect Sheet" => "Chroń arkusz", + "Protect Structure and Windows" => "Chroń strukturę i okna", + "Unprotect Workbook" => "Nie chroń skoroszytu", + "Unprotect Sheet" => "Nie chroń arkusza", + "Password" => "Hasło", + "Unhide" => "Odkryj", + "Paste Special" => "Wklejanie specjalne", + "Series" => "Seria danych", + "New Comment" => "Nowy komentarz", + "Insert Hyperlink" => "Wstawianie hiperlinku", + "Create Sparklines" => "Tworzenie wykresów przebiegu w czasie", + "Page Setup" => "Ustawienia strony", + "Convert Text to Columns" => "Konwertowanie tekstu na kolumny", + "Remove Duplicates" => "Usuń duplikaty", + "Goal Seek" => "Szukaj wyniku", + "Create Forecast Worksheet" => "Tworzenie arkusza prognozy", + "Solver Parameters" => "Parametry dodatku Solver", + "Solver Results" => "Wyniki dodatku Solver", + "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", + "Go To Special" => "Przechodzenie do (specjalnie)", + "Name Manager" => "Menedżer nazw", + "Conditional Formatting Rules Manager" => "Menedżer reguł formatowania warunkowego", + "Search Commands" => "Wyszukaj polecenia", + "Agent Control" => "Sterowanie agentem", + "About GridCraft" => "O programie GridCraft", + "Insert Chart" => "Wstawianie wykresu", + "Select Data Source" => "Wybieranie źródła danych", + "Action Journal" => "Dziennik akcji", + "Macros" => "Makra", + "Workbook Statistics" => "Statystyki skoroszytu", + "Accessibility" => "Ułatwienia dostępu", + "Chart Title" => "Tytuł wykresu", + "Place Picture in Cell" => "Umieść obraz w komórce", + "Place Picture over Cells" => "Umieść obraz na komórkach", + "Pick From List" => "Wybierz z listy", + "Save a Copy" => "Zapisz kopię", + "Save Changes?" => "Zapisać zmiany?", + "Shift cells right" => "Przesuń komórki w prawo", + "Shift cells down" => "Przesuń komórki w dół", + "Shift cells left" => "Przesuń komórki w lewo", + "Shift cells up" => "Przesuń komórki w górę", + "Entire row" => "Cały wiersz", + "Entire column" => "Cała kolumna", + "Row height (pixels):" => "Wysokość wiersza (piksele):", + "Column width (characters):" => "Szerokość kolumny (znaki):", + "Standard column width (pixels):" => "Standardowa szerokość kolumny (piksele):", + "Name:" => "Nazwa:", + "Before sheet (position, 0 = first):" => "Przed arkuszem (pozycja, 0 = pierwszy):", + "Create a copy" => "Utwórz kopię", + "Sheets: {sheets}" => "Arkusze: {sheets}", + "Password (optional):" => "Hasło (opcjonalnie):", + "Password:" => "Hasło:", + "Allow all users of this sheet to:" => "Zezwalaj wszystkim użytkownikom tego arkusza na:", + "Format cells" => "Formatowanie komórek", + "Format columns" => "Formatowanie kolumn", + "Format rows" => "Formatowanie wierszy", + "Insert rows" => "Wstawianie wierszy", + "Delete rows" => "Usuwanie wierszy", + "Use AutoFilter" => "Używanie Autofiltru", + " (password, unlocked)" => " (hasło, odblokowany)", + " (password)" => " (hasło)", + "No ranges yet." => "Nie ma jeszcze zakresów.", + "Ranges that stay editable when the sheet is protected:" => "Zakresy, które można edytować, gdy arkusz jest chroniony:", + "Action" => "Akcja", + "Modify" => "Modyfikuj", + "Unlock with password" => "Odblokuj hasłem", + "Title:" => "Tytuł:", + "Refers to cells:" => "Odwołuje się do komórek:", + "Range password:" => "Hasło zakresu:", + "New title (Modify):" => "Nowy tytuł (Modyfikuj):", + "Remove the password (Modify)" => "Usuń hasło (Modyfikuj)", + "'{file}' is protected." => "Plik „{file}” jest chroniony.", + "Unhide sheet:" => "Odkryj arkusz:", + "Hidden: {sheets}" => "Ukryte: {sheets}", + "All" => "Wszystko", + "Formulas (F)" => "Formuły (F)", + "Values (V)" => "Wartości (V)", + "Formats (T)" => "Formaty (T)", + "Comments and notes (C)" => "Komentarze i notatki (C)", + "Validation" => "Sprawdzanie poprawności", + "All except borders" => "Wszystko z wyjątkiem obramowań", + "Column widths" => "Szerokości kolumn", + "Formulas and number formats" => "Formuły i formaty liczb", + "Values and number formats" => "Wartości i formaty liczb", + "Operation" => "Operacja", + "Add" => "Dodaj", + "Subtract" => "Odejmij", + "Multiply" => "Pomnóż", + "Divide" => "Podziel", + "Skip blanks" => "Pomijaj puste", + "Transpose (E)" => "Transpozycja (E)", + "Series in" => "Seria w", + "Columns" => "Kolumny", + "Rows" => "Wiersze", + "Type" => "Typ", + "Linear" => "Liniowy", + "Growth" => "Wzrost", + "Date unit" => "Jednostka daty", + "Day" => "Dzień", + "Weekday" => "Dzień powszedni", + "Month" => "Miesiąc", + "Year" => "Rok", + "Step value:" => "Wartość kroku:", + "Stop value:" => "Wartość końcowa:", + "Start a conversation:" => "Rozpocznij konwersację:", + "Note:" => "Notatka:", + "Display:" => "Tekst do wyświetlenia:", + "Link to (web address, e-mail or Sheet!A1):" => "Łącze do (adres internetowy, e-mail lub Arkusz1!A1):", + "ScreenTip:" => "Etykietka ekranowa:", + "Data Range:" => "Zakres danych:", + "Location Range:" => "Zakres lokalizacji:", + "Markers" => "Znaczniki", + "Footer:" => "Stopka:", + "Left margin (in):" => "Lewy margines (cale):", + "Right margin (in):" => "Prawy margines (cale):", + "Top margin (in):" => "Górny margines (cale):", + "Bottom margin (in):" => "Dolny margines (cale):", + "Header (in):" => "Nagłówek (cale):", + "Footer (in):" => "Stopka (cale):", + "Tab" => "Tabulator", + "Semicolon" => "Średnik", + "Comma" => "Przecinek", + "Space" => "Spacja", + "Other:" => "Inny:", + "Treat consecutive delimiters as one" => "Traktuj kolejne ograniczniki jako jeden", + "Destination:" => "Miejsce docelowe:", + "My data has headers" => "Moje dane mają nagłówki", + "Columns (blank = all, e.g. A,C):" => "Kolumny (puste = wszystkie, np. A,C):", + "Timeline range (blank: first column of the selection):" => "Zakres osi czasu (puste: pierwsza kolumna zaznaczenia):", + "Values range (blank: second column of the selection):" => "Zakres wartości (puste: druga kolumna zaznaczenia):", + "Confidence interval" => "Przedział ufności", + "Confidence level (%):" => "Poziom ufności (%):", + "Seasonality (auto, 0 for none, or a season length):" => "Sezonowość (auto, 0 = brak albo długość sezonu):", + "Fill missing points using" => "Wypełnij brakujące punkty przy użyciu", + "Interpolation" => "Interpolacja", + "Zeros" => "Zera", + "Aggregate duplicates using" => "Agreguj duplikaty przy użyciu", + "Average" => "Średnia", + "Count" => "Licznik", + "CountA" => "Licznik niepustych", + "Max" => "Maksimum", + "Median" => "Mediana", + "Min" => "Minimum", + "Sum" => "Suma", + "Product" => "Iloczyn", + "Include forecast statistics" => "Uwzględnij statystyki prognozy", + "Chart" => "Wykres", + "Line chart" => "Wykres liniowy", + "Column chart" => "Wykres kolumnowy", + "At each change in (column letter):" => "Przy każdej zmianie w (litera kolumny):", + "Use function" => "Użyj funkcji", + "Add subtotal to (columns, e.g. C,D):" => "Dodaj sumę częściową do (kolumny, np. C,D):", + "Magnification" => "Powiększenie", + "Custom (%):" => "Niestandardowe (%):", + "Scope (Workbook or sheet name):" => "Zakres (skoroszyt lub nazwa arkusza):", + "Comment:" => "Komentarz:", + "Refers to:" => "Odwołuje się do:", + "Allow" => "Dozwolone", + "Any value" => "Dowolna wartość", + "Whole number" => "Pełna liczba", + "Decimal" => "Dziesiętne", + "List" => "Lista", + "Text length" => "Długość tekstu", + "between" => "między", + "not between" => "nie między", + "equal to" => "równe", + "not equal to" => "różne od", + "greater than" => "większe niż", + "less than" => "mniejsze niż", + "greater than or equal to" => "większe lub równe", + "less than or equal to" => "mniejsze lub równe", + "Minimum / Source / Formula:" => "Minimum / Źródło / Formuła:", + "Maximum:" => "Maksimum:", + "Ignore blank" => "Ignoruj puste", + "In-cell dropdown" => "Rozwinięcie w komórce", + "Input message:" => "Komunikat wejściowy:", + "Error style" => "Styl błędu", + "Stop" => "Zatrzymaj", + "Warning" => "Ostrzeżenie", + "Information" => "Informacje", + "Error message:" => "Komunikat o błędzie:", + "Format cells that are (value):" => "Formatuj komórki, które są (wartość):", + "and:" => "i:", + "Format cells that contain the text:" => "Formatuj komórki zawierające tekst:", + "Format cells that contain a date occurring:" => "Formatuj komórki zawierające datę:", + "Yesterday" => "Wczoraj", + "Today" => "Dzisiaj", + "Tomorrow" => "Jutro", + "In the last 7 days" => "W ciągu ostatnich 7 dni", + "Last week" => "W zeszłym tygodniu", + "This week" => "W tym tygodniu", + "Next week" => "W przyszłym tygodniu", + "Last month" => "W zeszłym miesiącu", + "This month" => "W tym miesiącu", + "Next month" => "W przyszłym miesiącu", + "Format values where this formula is true:" => "Formatuj wartości, dla których ta formuła jest prawdziwa:", + "with" => "z", + "Light Red Fill with Dark Red Text" => "Jasnoczerwone wypełnienie z ciemnoczerwonym tekstem", + "Yellow Fill with Dark Yellow Text" => "Żółte wypełnienie z ciemnożółtym tekstem", + "Green Fill with Dark Green Text" => "Zielone wypełnienie z ciemnozielonym tekstem", + "Light Red Fill" => "Jasnoczerwone wypełnienie", + "Red Text" => "Czerwony tekst", + "Red Border" => "Czerwone obramowanie", + "Formula:" => "Formuła:", + "Select" => "Zaznacz", + "Blanks" => "Puste", + "Errors" => "Błędy", + "Numbers" => "Liczby", + "Current region" => "Bieżący obszar", + "Last cell" => "Ostatnia komórka", + "Visible cells only" => "Tylko widoczne komórki", + "Conditional formats" => "Formatowanie warunkowe", + "Data validation" => "Poprawność danych", + "Horizontal axis title:" => "Tytuł osi poziomej:", + "Vertical axis title:" => "Tytuł osi pionowej:", + "No picture selected" => "Nie wybrano obrazu", + "Picture file path (PNG or JPEG):" => "Ścieżka pliku obrazu (PNG lub JPEG):", + "Description (optional):" => "Opis (opcjonalnie):", + "Fits inside the selected cell and moves with its content." => "Mieści się w zaznaczonej komórce i przesuwa się razem z jej zawartością.", + "Choose a PNG or JPEG no larger than 16 MB." => "Wybierz plik PNG lub JPEG nie większy niż 16 MB.", + "About" => "O programie", + "Contributors" => "Współtwórcy", + "Models" => "Modele", + "Version {version}" => "Wersja {version}", + "Join the ArtCraft Discord" => "Dołącz do Discorda ArtCraft", + "This workbook has no scripts or macros." => "Ten skoroszyt nie zawiera skryptów ani makr.", + "(unnamed)" => "(bez nazwy)", + "— VBA project (preserved, not run)" => "- projekt VBA (zachowany, nieuruchamiany)", + "— 1 step" => "- 1 krok", + "— {steps} steps" => "- liczba kroków: {steps}", + "Alignment" => "Wyrównanie", + "Font" => "Czcionka", + "Border" => "Obramowanie", + "Format Cells|Fill" => "Wypełnienie", + "Protection" => "Ochrona", + "Category:" => "Kategoria:", + "Sample" => "Przykład", + "Type (custom format code):" => "Typ (kod formatu niestandardowego):", + "Vertical" => "Pionowo", + "Justify" => "Wyjustuj", + "Center Across Selection" => "Wyrównaj do środka zaznaczenia", + "Distributed" => "Rozłożone", + "Top" => "Do góry", + "Bottom" => "Do dołu", + "Indent" => "Wcięcie", + "Orientation (degrees)" => "Orientacja (stopnie)", + "Wrap text" => "Zawijaj tekst", + "Shrink to fit" => "Zmniejsz, aby dopasować", + "Bold" => "Pogrubienie", + "Italic" => "Kursywa", + "None" => "Brak", + "Single" => "Pojedyncze", + "Double" => "Podwójne", + "Single Accounting" => "Pojedyncze księgowe", + "Double Accounting" => "Podwójne księgowe", + "Superscript" => "Indeks górny", + "Subscript" => "Indeks dolny", + "Color:" => "Kolor:", + "Outline" => "Kontur", + "Diagonal ↘" => "Ukośna ↘", + "Diagonal ↗" => "Ukośna ↗", + "none" => "brak", + "thin" => "cienka", + "medium" => "średnia", + "thick" => "gruba", + "dashed" => "kreskowana", + "dotted" => "kropkowana", + "double" => "podwójna", + "Background Color:" => "Kolor tła:", + "Locked" => "Zablokuj", + "Hidden" => "Ukryj", + "Search:" => "Szukaj:", + "e.g. lookup, average, date" => "np. wyszukaj, średnia, data", + "Date & Time" => "Data i godzina", + "Lookup & Reference" => "Wyszukiwanie i odwołania", + "Math & Trig" => "Matematyczne i trygonometryczne", + "Statistical" => "Statystyczne", + "Engineering" => "Inżynierskie", + "Database" => "Baza danych", + "Compatibility" => "Zgodność", + "Web" => "Sieć Web", + "Cube" => "Moduł", + "Add-in and Automation" => "Dodatki i automatyzacja", + "Find" => "Znajdź", + "Replace" => "Zamień", + "Find what:" => "Znajdź:", + "Replace with:" => "Zamień na:", + "Match case" => "Uwzględniaj wielkość liter", + "Match entire cell contents" => "Dopasuj do całej zawartości komórki", + "Within workbook" => "W obrębie skoroszytu", + "Replace All" => "Zamień wszystko", + "Find All" => "Znajdź wszystko", + "Find Next" => "Znajdź następny", + "All done. We made {count} replacements." => "Gotowe. Liczba zamian: {count}.", + "{count} cell(s) found" => "Znalezione komórki: {count}", + "Reference:" => "Odwołanie:", + "Special…" => "Specjalnie…", + "My list has headers" => "Moja lista ma nagłówki", + "Sort by" => "Sortuj według", + "Then by" => "Następnie według", + "Cell Values" => "Wartości komórek", + "Cell Color" => "Kolor komórki", + "A to Z" => "Od A do Z", + "Z to A" => "Od Z do A", + "+ Add Level" => "+ Dodaj poziom", + "New…" => "Nowy…", + "Edit…" => "Edytuj…", + "Name" => "Nazwa", + "Value" => "Wartość", + "Refers To" => "Odwołuje się do", + "Scope" => "Zakres", + "Workbook" => "Skoroszyt", + "No names defined" => "Brak zdefiniowanych nazw", + "Recommended Charts" => "Polecane wykresy", + "All Charts" => "Wszystkie wykresy", + "Clustered Column" => "Kolumnowy grupowany", + "Stacked Column" => "Kolumnowy skumulowany", + "100% Stacked Column" => "Kolumnowy 100% skumulowany", + "Clustered Bar" => "Słupkowy grupowany", + "Stacked Bar" => "Słupkowy skumulowany", + "100% Stacked Bar" => "Słupkowy 100% skumulowany", + "Line" => "Liniowy", + "Line with Markers" => "Liniowy ze znacznikami", + "Stacked Line" => "Liniowy skumulowany", + "Pie" => "Kołowy", + "Doughnut" => "Pierścieniowy", + "Area" => "Warstwowy", + "Stacked Area" => "Warstwowy skumulowany", + "Scatter" => "Punktowy", + "Scatter with Lines" => "Punktowy z liniami", + "Radar" => "Radarowy", + "Histogram" => "Histogram", + "Waterfall" => "Kaskadowy", + "Funnel" => "Lejkowy", + "Combo" => "Kombi", + "Chart data range:" => "Zakres danych wykresu:", + "Legend Entries (Series)" => "Wpisy legendy (serie)", + "Series{n}" => "Seria{n}", + "Keep" => "Zachowaj", + "Remove" => "Usuń", + "Horizontal (Category) Axis Labels:" => "Etykiety osi poziomej (kategorii):", + "There are no conditional formatting rules on this sheet." => "W tym arkuszu nie ma reguł formatowania warunkowego.", + "applies to" => "dotyczy", + "Type a command, e.g. freeze, chart, bold…" => "Wpisz polecenie, np. zablokuj, wykres, pogrubienie…", + "Set cell:" => "Ustaw komórkę:", + "To value:" => "Wartość docelowa:", + "By changing cell:" => "Zmieniając komórkę:", + "Goal Seeking found a solution: {value}" => "Szukanie wyniku znalazło rozwiązanie: {value}", + "Analysis Tools" => "Narzędzia analizy", + "Anova: Single Factor" => "Analiza wariancji: jednoczynnikowa", + "Correlation" => "Korelacja", + "Covariance" => "Kowariancja", + "Descriptive Statistics" => "Statystyka opisowa", + "Exponential Smoothing" => "Wygładzanie wykładnicze", + "F-Test Two-Sample for Variances" => "Test F: z dwiema próbami dla wariancji", + "Moving Average" => "Średnia ruchoma", + "Rank and Percentile" => "Ranga i percentyl", + "Regression" => "Regresja", + "t-Test: Paired Two Sample for Means" => "Test t: par skojarzonych z dwiema próbami dla średnich", + "t-Test: Two-Sample Assuming Equal Variances" => "Test t: z dwiema próbami zakładający równe wariancje", + "t-Test: Two-Sample Assuming Unequal Variances" => "Test t: z dwiema próbami zakładający nierówne wariancje", + "Output options" => "Opcje wyjścia", + "Output Range:" => "Zakres wyjściowy:", + "New Worksheet Ply:" => "Nowy arkusz:", + "Grouped By:" => "Grupowanie:", + "Labels in first row" => "Etykiety w pierwszym wierszu", + "Input Range:" => "Zakres wejściowy:", + "Variable 1 Range:" => "Zakres zmiennej 1:", + "Variable 2 Range:" => "Zakres zmiennej 2:", + "Summary statistics" => "Statystyki podsumowujące", + "Confidence Level for Mean (%), blank for none:" => "Poziom ufności dla średniej (%), puste = brak:", + "Kth Largest (blank for none):" => "K-ty największy (puste = brak):", + "Kth Smallest (blank for none):" => "K-ty najmniejszy (puste = brak):", + "Bin Range (blank for automatic bins):" => "Zakres przedziałów (puste = automatyczne przedziały):", + "Pareto (sorted histogram)" => "Pareto (posortowany histogram)", + "Cumulative Percentage" => "Procent skumulowany", + "Alpha:" => "Alfa:", + "Input Y Range:" => "Zakres wejściowy Y:", + "Input X Range:" => "Zakres wejściowy X:", + "Constant is Zero" => "Stała równa zero", + "Confidence Level (%):" => "Poziom ufności (%):", + "Residuals" => "Składniki resztowe", + "Standardized Residuals" => "Standardowe składniki resztowe", + "Normal Probability" => "Prawdopodobieństwo normalne", + "Interval:" => "Interwał:", + "Standard Errors" => "Błędy standardowe", + "Damping factor:" => "Współczynnik tłumienia:", + "Hypothesized Mean Difference:" => "Hipotetyczna różnica średnich:", + "Set Objective:" => "Ustaw cel:", + "To:" => "Na:", + "Value Of:" => "Wartość:", + "By Changing Variable Cells:" => "Przez zmienianie komórek zmiennych:", + "Subject to the Constraints:" => "Podlegające ograniczeniom:", + "Make Unconstrained Variables Non-Negative" => "Ustaw zmienne nieograniczone jako nieujemne", + "Select a Solving Method:" => "Wybierz metodę rozwiązywania:", + "GRG Nonlinear" => "Nieliniowa GRG", + "Solve" => "Rozwiąż", + "Keep Solver Solution" => "Zachowaj rozwiązanie dodatku Solver", + "Restore Original Values" => "Przywróć wartości oryginalne", + "The spelling check is complete for the entire sheet." => "Sprawdzanie pisowni w całym arkuszu zostało zakończone.", + "Not in Dictionary ({cell}):" => "Brak w słowniku ({cell}):", + "Suggestions:" => "Sugestie:", + "(No suggestions)" => "(Brak sugestii)", + "Ignore Once" => "Zignoruj raz", + "Add to Dictionary" => "Dodaj do słownika", + "Change" => "Zmień", + "Change All" => "Zmień wszystko", + "$ English (United States)" => "$ Angielski (Stany Zjednoczone)", + "Add Chart Element" => "Dodaj element wykresu", + "Align Left" => "Wyrównaj do lewej", + "Align Right" => "Wyrównaj do prawej", + "Bottom Align" => "Wyrównaj do dołu", + "Calculate Now (F9)" => "Oblicz teraz (F9)", + "Calculate Sheet (⇧F9)" => "Oblicz arkusz (⇧F9)", + "Calculation Options" => "Opcje obliczania", + "Cell Styles" => "Style komórki", + "Change Chart Type" => "Zmień typ wykresu", + "Check Accessibility" => "Sprawdź ułatwienia dostępu", + "Clear Filter" => "Wyczyść filtr", + "Column or Bar" => "Kolumnowy lub słupkowy", + "Comments pane" => "Okienko komentarzy", + "Convert the last ink stroke to a shape" => "Przekształć ostatnie pociągnięcie pióra w kształt", + "Convert to Range" => "Konwertuj na zakres", + "Copy" => "Kopiuj", + "Custom Sort" => "Sortowanie niestandardowe", + "Cut" => "Wytnij", + "Define Name…" => "Definiuj nazwę…", + "Delete Comment" => "Usuń komentarz", + "Delete Sheet" => "Usuń arkusz", + "Delete chart" => "Usuń wykres", + "Delete…" => "Usuń…", + "Dismiss" => "Zamknij", + "Down" => "W dół", + "Edit chart title" => "Edytuj tytuł wykresu", + "Erase ink strokes" => "Wymaż pociągnięcia pióra", + "Every command run in this session (replayable)" => "Każde polecenie uruchomione w tej sesji (można je odtworzyć)", + "Expand formula bar" => "Rozwiń pasek formuły", + "Filter (⌘⇧F)" => "Filtruj (⌘⇧F)", + "Filter by Selected Cell's Value" => "Filtruj według wartości zaznaczonej komórki", + "Find & Select" => "Znajdź i zaznacz", + "Flash Fill (⌘E)" => "Wypełnianie błyskawiczne (⌘E)", + "Forecast Sheet" => "Arkusz prognozy", + "Forecast end (date or number; blank: a third of the history):" => "Koniec prognozy (data lub liczba; puste: jedna trzecia historii):", + "Format Chart pane" => "Okienko formatowania wykresu", + "Format as Table" => "Formatuj jako tabelę", + "Hide" => "Ukryj", + "Hide Sheet" => "Ukryj arkusz", + "Hierarchy & more" => "Hierarchiczne i inne", + "Highlighter" => "Zakreślacz", + "How agents drive GridCraft (MCP / control channel)" => "Jak agenci sterują programem GridCraft (MCP / kanał sterowania)", + "Import a text or CSV file" => "Importuj plik tekstowy lub CSV", + "Insert Function" => "Wstaw funkcję", + "Insert Function (⇧F3)" => "Wstaw funkcję (⇧F3)", + "Insert Link (⌘K)" => "Wstaw łącze (⌘K)", + "Insert Sheet" => "Wstaw arkusz", + "Insert Slicer" => "Wstaw fragmentator", + "Insert a picture" => "Wstaw obraz", + "Insert a slicer to filter a table" => "Wstaw fragmentator, aby filtrować tabelę", + "Insert an icon" => "Wstaw ikonę", + "Insert…" => "Wstaw…", + "Line or Area" => "Liniowy lub warstwowy", + "Link…" => "Łącze…", + "Macros and scripts in this workbook (Alt+F8)" => "Makra i skrypty w tym skoroszycie (Alt+F8)", + "Middle Align" => "Wyrównaj do środka", + "More commands" => "Więcej poleceń", + "Move or Copy Sheet…" => "Przenieś lub kopiuj arkusz…", + "New sheet (⇧F11)" => "Nowy arkusz (⇧F11)", + "Next sheet" => "Następny arkusz", + "Page Breaks" => "Podziały stron", + "Paper Size" => "Rozmiar papieru", + "Paste (⌘V)" => "Wklej (⌘V)", + "Pick From Drop-down List…" => "Wybierz z listy rozwijanej…", + "Pie or Doughnut" => "Kołowy lub pierścieniowy", + "Previous sheet" => "Poprzedni arkusz", + "Print Area" => "Obszar wydruku", + "Print Titles" => "Tytuły wydruku", + "Protect Workbook structure" => "Chroń strukturę skoroszytu", + "Redo (⌘Y)" => "Ponów (⌘Y)", + "Save (⌘S)" => "Zapisz (⌘S)", + "Scatter or Bubble" => "Punktowy lub bąbelkowy", + "Search and run any command" => "Wyszukaj i uruchom dowolne polecenie", + "Search commands (⌘⇧Space)" => "Wyszukaj polecenia (⌘⇧Spacja)", + "Select and move ink and shapes" => "Zaznaczaj i przesuwaj pismo odręczne oraz kształty", + "Series…" => "Seria danych…", + "Share: export or save a copy" => "Udostępnij: eksportuj lub zapisz kopię", + "Show Formulas (⌃`)" => "Pokaż formuły (⌃`)", + "Slicer" => "Fragmentator", + "Sort & Filter" => "Sortuj i filtruj", + "Spelling (F7)" => "Pisownia (F7)", + "Statistic" => "Statystyczne", + "Switch Row/Column" => "Przełącz wiersz/kolumnę", + "Table (⌘T)" => "Tabela (⌘T)", + "Table Styles" => "Style tabeli", + "Text to Columns" => "Tekst jako kolumny", + "The output range already holds data. Replace it with the report?" => "Zakres wyjściowy zawiera już dane. Zastąpić je raportem?", + "Top Align" => "Wyrównaj do góry", + "Undo (⌘Z)" => "Cofnij (⌘Z)", + "Undo list" => "Lista cofania", + "Unhide Sheet…" => "Odkryj arkusz…", + "Up" => "W górę", + "Version, contributors and the AI models that helped" => "Wersja, współtwórcy i modele AI, które pomogły", + "What-If Analysis" => "Analiza warunkowa", + "Zoom In" => "Powiększ", + "Zoom Out" => "Pomniejsz", + "Zoom to 100%" => "Powiększenie 100%", + "Zoom to Selection" => "Powiększ do zaznaczenia", + "Zoom…" => "Powiększenie…", + "£ English (United Kingdom)" => "£ Angielski (Wielka Brytania)", + "¥ Japanese" => "¥ Japoński", + "€ Euro" => "€ Euro", + "Tab|Insert" => "Wstawianie", + "AutoSave" => "Autozapis", + "Insert\nSlicer" => "Wstaw\nfragmentator", + "Pen\nBlack" => "Pióro\nczarne", + "Pen\nBlue" => "Pióro\nniebieskie", + "Pen\nRed" => "Pióro\nczerwone", + "Protect\nSheet" => "Chroń\narkusz", + "Watch\nWindow" => "Okno\nczujki", + "Workbook\nStatistics" => "Statystyki\nskoroszytu", + "PivotTable Fields" => "Pola tabeli przestawnej", + "Choose fields to add to report:" => "Wybierz pola, które chcesz dodać do raportu:", + "Use a field's menu to move it between areas:" => "Przenoś pole między obszarami za pomocą jego menu:", + "Filters" => "Filtry", + "Values" => "Wartości", + "Move to Row Labels" => "Przenieś do etykiet wierszy", + "Move to Column Labels" => "Przenieś do etykiet kolumn", + "Move to Report Filter" => "Przenieś do filtru raportu", + "Move to Values" => "Przenieś do wartości", + "Summarize by Sum" => "Podsumuj według: Suma", + "Summarize by Count" => "Podsumuj według: Licznik", + "Summarize by Average" => "Podsumuj według: Średnia", + "Summarize by Max" => "Podsumuj według: Maksimum", + "Summarize by Min" => "Podsumuj według: Minimum", + "Show as No Calculation" => "Pokaż jako: Bez obliczeń", + "Show as % of Grand Total" => "Pokaż jako: % sumy końcowej", + "Show as Running Total" => "Pokaż jako: Suma bieżąca", + "Show as Rank" => "Pokaż jako: Pozycja", + "Group by Years" => "Grupuj według lat", + "Group by Quarters" => "Grupuj według kwartałów", + "Group by Months" => "Grupuj według miesięcy", + "Remove Field" => "Usuń pole", + "Refresh" => "Odśwież", + "Calculated Field…" => "Pole obliczeniowe…", + "Report Layout" => "Układ raportu", + "Compact" => "Kompaktowy", + "Tabular" => "Tabelaryczny", + _ => return None, + }) +} From 694809d6c5f3008c07d4c19f9dc9a247a3332f88 Mon Sep 17 00:00:00 2001 From: mbielski1337 Date: Tue, 13 Oct 2026 17:02:21 +0200 Subject: [PATCH 042/446] i18n: register Polish (pl) language --- crates/ui-egui/src/i18n.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..a2a41e309 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -14,6 +14,7 @@ mod de; mod es; mod fr; mod ko; +mod pl; mod pt; mod ru; mod sr; @@ -35,10 +36,12 @@ pub enum Language { Cs, Fr, Sr, + Pl, } impl Language { - pub const ALL: [Self; 11] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr]; + pub const ALL: [Self; 12] = + [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr, Self::Pl]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { @@ -54,6 +57,7 @@ impl Language { Self::Cs => "Čeština", Self::Fr => "Français", Self::Sr => "Srpski", + Self::Pl => "Polski", } } @@ -71,6 +75,7 @@ impl Language { Self::Cs => "cs", Self::Fr => "fr", Self::Sr => "sr", + Self::Pl => "pl", } } @@ -91,6 +96,7 @@ impl Language { "cs" => Some(Self::Cs), "fr" => Some(Self::Fr), "sr" => Some(Self::Sr), + "pl" => Some(Self::Pl), _ => None, } } @@ -129,6 +135,7 @@ impl Language { Self::Cs => cs::translate(text), Self::Fr => fr::translate(text), Self::Sr => sr::translate(text), + Self::Pl => pl::translate(text), }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } @@ -611,6 +618,7 @@ mod tests { assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}"); assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}"); + assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); @@ -644,6 +652,9 @@ mod tests { assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija"); assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija"); + assert_eq!(Language::Pl.tr("Data"), "Dane"); + assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja"); assert_eq!(Language::parse("xx"), None); } @@ -685,6 +696,9 @@ mod tests { assert_eq!(Language::from_tag("sr-Latn-RS"), Some(Language::Sr)); assert_eq!(Language::from_tag("sr-RS"), Some(Language::Sr)); assert_eq!(Language::parse("sr"), Some(Language::Sr)); + assert_eq!(Language::from_tag("pl-PL"), Some(Language::Pl)); + assert_eq!(Language::from_tag("pl_PL.UTF-8"), Some(Language::Pl)); + assert_eq!(Language::parse("pl"), Some(Language::Pl)); } #[test] From 2b5330109271170133d327b063988ea669d84e44 Mon Sep 17 00:00:00 2001 From: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:14:56 +0800 Subject: [PATCH 043/446] fix(grid): keep cursor stable during cell dragging (#936) Co-Authored-By: Codex --- crates/ui-egui/src/grid.rs | 135 ++++++++---- crates/ui-egui/tests/drag_cursor.rs | 307 ++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 400 insertions(+), 46 deletions(-) create mode 100644 crates/ui-egui/tests/drag_cursor.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..1cc1e2689 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -752,6 +752,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); interact(app, ui, &resp, &geo, sh, &wb); + update_cursor(app, ui, &resp, &geo, sh); crate::text_box::show(app, ui, &geo, sh); if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { in_cell_editor(app, ui, &geo, sh, &wb); @@ -1774,6 +1775,95 @@ pub(crate) fn paint_headers( /// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. const OBJECT_RESIZE_RADIUS: f32 = 10.0; +/// Decide after interaction so drag start and release use the new state in the same frame. +fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx(); + let pointer = ctx.input(|i| i.pointer.clone()); + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Hover bookkeeping also serves cell notes, independently of who owns the cursor. + if resp.hovered() + && let Some(p) = pos + && !freeze_cols.contains(p) + && !freeze_rows.contains(p) + && !matches!(app.grid.drag, Drag::Freeze { .. }) + { + app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); + } + // Draw tools own the cursor over cells, including while recording a stroke. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + return; + } + // A held drag owns the cursor even outside the grid; a press below egui's threshold still + // has Drag::None and uses hover. A click can briefly leave a drag state after release. + if resp.dragged() && pointer.primary_down() { + let cursor = match app.grid.drag { + Drag::None => None, + Drag::Select => Some(CursorIcon::Cell), + Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Fill { .. } => Some(CursorIcon::Crosshair), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), + Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), + Drag::Freeze { cols, .. } => Some(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), + Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + }; + if let Some(cursor) = cursor { + ctx.set_cursor_icon(cursor); + return; + } + } + if !resp.hovered() { + return; + } + let Some(p) = pos else { return }; + if freeze_cols.contains(p) || freeze_rows.contains(p) { + let cols = freeze_cols.contains(p); + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); + } + return; + } + let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); + let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + if app.grid.drag == Drag::None && near_corner { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if in_col_header && col_edge(geo, sh, p.x).is_some() { + ctx.set_cursor_icon(CursorIcon::ResizeColumn); + } else if in_row_header && row_edge(geo, sh, p.y).is_some() { + ctx.set_cursor_icon(CursorIcon::ResizeRow); + } else if on_handle && app.editor.is_none() { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } + } else if in_cells && app.editor.is_none() && on_cur_border { + ctx.set_cursor_icon(CursorIcon::Move); + } else if in_cells { + ctx.set_cursor_icon(CursorIcon::Cell); + } else if in_col_header { + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); + } +} + fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { @@ -1801,51 +1891,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Hover cursors. - if resp.hovered() - && let Some(p) = pos - && (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. })) - { - let cols = match app.grid.drag { - Drag::Freeze { cols, .. } => cols, - _ => freeze_cols.contains(p), - }; - ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); - if app.grid.drag == Drag::None && !pointer.any_down() { - let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; - resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip)); - } - } else if resp.hovered() - && let Some(p) = pos - { - let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); - let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some(); - let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); - let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); - let near_object_corner = app.grid.drag == Drag::None && near_corner; - if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) { - ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if near_col_edge { - ctx.set_cursor_icon(CursorIcon::ResizeColumn); - } else if near_row_edge { - ctx.set_cursor_icon(CursorIcon::ResizeRow); - } else if on_handle(p) && app.editor.is_none() { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.grid.drag == Drag::None && !pointer.any_down() { - resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); - } - } else if in_cells && app.editor.is_none() && on_cur_border(p) { - ctx.set_cursor_icon(CursorIcon::Move); - } else if in_cells { - ctx.set_cursor_icon(CursorIcon::Cell); - } else if in_col_header { - let col = geo.col_at(sh, p.x); - let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); - ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); - } - app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); - } - // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. if !app.session.draw_tool.is_empty() && in_cells { ctx.set_cursor_icon(CursorIcon::Crosshair); diff --git a/crates/ui-egui/tests/drag_cursor.rs b/crates/ui-egui/tests/drag_cursor.rs new file mode 100644 index 000000000..3198f044f --- /dev/null +++ b/crates/ui-egui/tests/drag_cursor.rs @@ -0,0 +1,307 @@ +//! Drag cursors are asserted frame by frame through real egui pointer input (#936). + +use egui::{CursorIcon, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef, Value}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::{Drag, Geo, freeze_handles}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"], [1, 2]]})).unwrap(); + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> Geo { + h.state().grid.geo.clone().unwrap() +} + +fn cell(h: &Harness, a1: &str) -> Rect { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + geo(h).screen_cell_rect(sh, CellRef::parse(a1).unwrap()) +} + +fn selection_rect(h: &Harness) -> Rect { + let d = h.state().session.active().unwrap(); + geo(h).range_rect(d.wb.active().unwrap(), d.selection.current()) +} + +fn frame(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(1); +} + +fn move_to(h: &mut Harness, pos: Pos2) { + frame(h, Event::PointerMoved(pos)); +} + +fn button(h: &mut Harness, pos: Pos2, pressed: bool) { + frame(h, Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn press(h: &mut Harness, pos: Pos2) { + move_to(h, pos); + button(h, pos, true); +} + +fn assert_cursor(h: &Harness, expected: CursorIcon) { + assert_eq!(h.output().platform_output.cursor_icon, expected, "drag state: {:?}", h.state().grid.drag); +} + +fn move_with_cursor(h: &mut Harness, pos: Pos2, expected: CursorIcon) { + move_to(h, pos); + assert_cursor(h, expected); + // Check the following idle frame too: hit targets may have moved with the selection. + h.run_steps(1); + assert_cursor(h, expected); +} + +#[test] +fn selecting_keeps_cell_cursor_over_borders_handles_frozen_panes_and_outside() { + for rtl in [false, true] { + let mut h = harness(); + h.state_mut().session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B2"})).unwrap(); + h.run_steps(2); + let start = cell(&h, "C3").center(); + press(&mut h, start); + assert_eq!(h.state().grid.drag, Drag::None, "a press below the threshold is not a drag"); + move_with_cursor(&mut h, start + vec2(1.0, 1.0), CursorIcon::Cell); + assert_eq!(h.state().grid.drag, Drag::None); + let inside = cell(&h, "D4").center(); + move_with_cursor(&mut h, inside, CursorIcon::Cell); + assert_eq!(h.state().grid.drag, Drag::Select); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C3:D4"); + + let r = selection_rect(&h); + let g = geo(&h); + let border = g.mirror_pos(pos2(r.right() - 1.0, r.bottom() - 8.0)); + let handle = g.mirror_pos(r.right_bottom() - vec2(1.0, 1.0)); + move_with_cursor(&mut h, border, CursorIcon::Cell); + move_with_cursor(&mut h, handle, CursorIcon::Cell); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C3:D4"); + // Cross into the frozen pane, then briefly leave the grid while still holding. + let frozen = cell(&h, "A1").center(); + move_with_cursor(&mut h, frozen, CursorIcon::Cell); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "A1:C3"); + let outside = pos2(g.rect.left() - 20.0, g.rect.center().y); + move_with_cursor(&mut h, outside, CursorIcon::Cell); + // Leaving the mirrored grid can auto-scroll, so use its current geometry on return. + let inside = cell(&h, "D4").center(); + move_with_cursor(&mut h, inside, CursorIcon::Cell); + let handle = geo(&h).mirror_pos(selection_rect(&h).right_bottom() - vec2(1.0, 1.0)); + move_with_cursor(&mut h, handle, CursorIcon::Cell); + button(&mut h, handle, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Crosshair); + let r = selection_rect(&h); + let border = geo(&h).mirror_pos(pos2(r.right() - 1.0, r.bottom() - 8.0)); + move_with_cursor(&mut h, border, CursorIcon::Move); + let inside = cell(&h, "D4").center(); + move_with_cursor(&mut h, inside, CursorIcon::Cell); + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.current().a1(), "C3:D4"); + assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(2.0)); + } +} + +#[test] +fn a_new_press_does_not_treat_a_clicks_transient_state_as_a_drag() { + let mut h = harness(); + let start = cell(&h, "C3").center(); + press(&mut h, start); + button(&mut h, start, false); + assert_eq!(h.state().grid.drag, Drag::Select, "a click briefly leaves a selection state"); + let handle = selection_rect(&h).right_bottom(); + // Another press can arrive before an idle frame clears that state. + h.input_mut().events.push(Event::PointerMoved(handle)); + button(&mut h, handle, true); + assert_cursor(&h, CursorIcon::Crosshair); + button(&mut h, handle, false); + assert_eq!(h.state().grid.drag, Drag::None); +} + +#[test] +fn fill_keeps_crosshair_and_still_extends_a_series() { + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap(); + h.run_steps(2); + let start = selection_rect(&h).right_bottom(); + press(&mut h, start); + let target = cell(&h, "E2").center(); + move_with_cursor(&mut h, target, CursorIcon::Crosshair); + assert!(matches!(h.state().grid.drag, Drag::Fill { .. })); + move_with_cursor(&mut h, target + vec2(1.0, 0.0), CursorIcon::Crosshair); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Cell); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!((0..5).map(|c| sh.value(CellRef::new(1, c))).collect::>(), (1..=5).map(|n| Value::Number(n as f64)).collect::>()); +} + +#[test] +fn moving_a_selection_keeps_move_and_still_moves_cells() { + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap(); + h.run_steps(2); + let r = selection_rect(&h); + let start = pos2(r.left() + 10.0, r.top() + 1.0); + press(&mut h, start); + let target = cell(&h, "D5").center(); + move_with_cursor(&mut h, target, CursorIcon::Move); + assert!(matches!(h.state().grid.drag, Drag::Move { .. })); + move_with_cursor(&mut h, target + vec2(1.0, 0.0), CursorIcon::Move); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + h.run_steps(1); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty); + assert_eq!(sh.value(CellRef::parse("D5").unwrap()), Value::Number(1.0)); + assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0)); +} + +#[test] +fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() { + for cols in [true, false] { + let mut h = harness(); + let g = geo(&h); + let r = cell(&h, "C3"); + let start = if cols { pos2(r.right(), g.head_top() + g.header_h * 0.5) } else { pos2(g.head_left() + g.header_w * 0.5, r.bottom()) }; + let cursor = if cols { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }; + press(&mut h, start); + let first = start + if cols { vec2(12.0, 0.0) } else { vec2(0.0, 12.0) }; + move_with_cursor(&mut h, first, cursor); + assert!(if cols { matches!(h.state().grid.drag, Drag::ResizeCol { .. }) } else { matches!(h.state().grid.drag, Drag::ResizeRow { .. }) }); + let target = cell(&h, "E6").center(); + move_with_cursor(&mut h, target, cursor); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Cell); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert!(if cols { sh.col_width(2) > sh.default_col_width } else { sh.row_height(2) > sh.default_row_height }); + } +} + +#[test] +fn freezing_keeps_the_axis_cursor_away_from_the_handle() { + for cols in [true, false] { + let mut h = harness(); + let g = geo(&h); + let handles = freeze_handles(&g); + let start = if cols { handles.0.center() } else { handles.1.center() }; + let cursor = if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }; + press(&mut h, start); + let r = cell(&h, "C4"); + let target = if cols { pos2(r.left() + 2.0, r.center().y) } else { pos2(r.center().x, r.top() + 2.0) }; + move_with_cursor(&mut h, target, cursor); + assert!(matches!(h.state().grid.drag, Drag::Freeze { .. })); + move_with_cursor(&mut h, target + vec2(1.0, 1.0), cursor); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Cell); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().freeze, Some(if cols { (0, 2) } else { (3, 0) })); + } +} + +#[test] +fn moving_columns_keeps_move_over_unselected_headers_and_cells() { + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "B:C"})).unwrap(); + h.run_steps(2); + let g = geo(&h); + let start = pos2(cell(&h, "B1").center().x, g.head_top() + g.header_h * 0.5); + press(&mut h, start); + let target = pos2(cell(&h, "E1").left() + 10.0, start.y); + move_with_cursor(&mut h, target, CursorIcon::Move); + assert!(matches!(h.state().grid.drag, Drag::MoveCols { .. })); + let inside = pos2(target.x, cell(&h, "E5").center().y); + move_with_cursor(&mut h, inside, CursorIcon::Move); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!((0..5).map(|c| sh.value(CellRef::new(0, c))).collect::>(), ["A", "D", "B", "C", "E"].map(|s| Value::Text(s.into()))); +} + +#[test] +fn selecting_headers_keeps_default_cursor_and_extends_whole_lines() { + for cols in [true, false] { + let mut h = harness(); + let g = geo(&h); + let r = cell(&h, "C3"); + let start = if cols { pos2(r.center().x, g.head_top() + g.header_h * 0.5) } else { pos2(g.head_left() + g.header_w * 0.5, r.center().y) }; + press(&mut h, start); + move_with_cursor(&mut h, start + if cols { vec2(12.0, 0.0) } else { vec2(0.0, 7.0) }, CursorIcon::Default); + assert!(if cols { matches!(h.state().grid.drag, Drag::Cols(_)) } else { matches!(h.state().grid.drag, Drag::Rows(_)) }); + let target = cell(&h, "E6").center(); + move_with_cursor(&mut h, target, CursorIcon::Default); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + let expected = if cols { RangeRef::cols(2, 4) } else { RangeRef::rows(2, 5) }; + assert_eq!(h.state().session.active().unwrap().selection.current(), expected); + } +} + +#[test] +fn object_move_and_resize_keep_their_cursors_and_update_the_object() { + for resize in [false, true] { + let mut h = harness(); + let result = h.state_mut().session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap(); + let id = result["chart"].as_u64().unwrap() as u32; + h.state_mut().selected_chart = Some(id); + h.run_steps(2); + let g = geo(&h); + let min = cell(&h, "D3").min; + let start = if resize { min + vec2(300.0, 200.0) * g.z } else { min + vec2(40.0, 40.0) }; + press(&mut h, start); + let target = start + vec2(60.0, 30.0); + let cursor = if resize { CursorIcon::ResizeNwSe } else { CursorIcon::Move }; + move_with_cursor(&mut h, target, cursor); + assert!(matches!(h.state().grid.drag, Drag::Object { resize: r, .. } if r == resize)); + move_with_cursor(&mut h, target + vec2(1.0, 1.0), cursor); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + let a = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor; + if resize { + assert!(a.width > 340.0 && a.height > 220.0); + } else { + assert_ne!((a.cell, a.dx, a.dy), (CellRef::parse("D3").unwrap(), 0.0, 0.0)); + assert_eq!((a.width, a.height), (300.0, 200.0)); + } + } +} + +#[test] +fn draw_tools_keep_crosshair_and_pen_still_creates_ink() { + for tool in ["draw.pen", "draw.eraser"] { + let mut h = harness(); + h.state_mut().session.execute(tool, json!({"on": true})).unwrap(); + let start = cell(&h, "C3").center(); + press(&mut h, start); + assert_cursor(&h, CursorIcon::Crosshair); + for a1 in ["D4", "E5", "F6"] { + let p = cell(&h, a1).center(); + move_with_cursor(&mut h, p, CursorIcon::Crosshair); + assert_eq!(h.state().grid.hover_cell, Some(CellRef::parse(a1).unwrap()), "drawing still tracks the hovered cell for notes"); + } + let end = cell(&h, "F6").center(); + button(&mut h, end, false); + assert_cursor(&h, CursorIcon::Crosshair); + if tool == "draw.pen" { + assert!( + h.state().session.active().unwrap().wb.active().unwrap().shapes.iter().any(|s| s.kind == gridcraft_engine::model::ShapeKind::Ink) + ); + } + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..c6735f82b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (stable grid drag cursors, #936) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -44,6 +44,7 @@ this logic, with one undo step. | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | +| Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | From caa9fd0f4861d1fa5a61c2f095f1105cdda2def8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:34 +0200 Subject: [PATCH 044/446] Fix row keys merging distinct text rows in UNIQUE and grouping (#1012) --- crates/functions/src/dynamic.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 2e29b416c..0e5c5aa17 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -86,8 +86,11 @@ fn key_of(v: &Value) -> String { fn row_key(r: &[Value]) -> String { let mut s = String::new(); for v in r { - s.push_str(&key_of(v)); - s.push('\u{1}'); + let k = key_of(v); + // Length prefix keeps the encoding unambiguous even when a text value contains the separator. + s.push_str(&k.len().to_string()); + s.push(':'); + s.push_str(&k); } s } @@ -1102,6 +1105,12 @@ mod tests { is_err(ev("SORTBY", vec![av(col(&[1.0, 2.0])), av(col(&[1.0, 2.0])), n(0.0)]), CellError::Value); } + #[test] + fn unique_keeps_rows_containing_separator_apart() { + let m = arr(vec![vec![tv("a"), tv("b\u{1}tc")], vec![tv("a\u{1}tb"), tv("c")]]); + assert_eq!(rows_of(&ev("UNIQUE", vec![av(m)])).len(), 2); + } + #[test] fn unique() { let v = arr(vec![vec![tv("a")], vec![tv("B")], vec![tv("A")], vec![tv("c")], vec![tv("b")]]); From 08902d925a63dda7ae223729ae0d0c4e5db127d0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:24 +0200 Subject: [PATCH 045/446] xlsx: keep the first group's series kind when importing a combination chart (#1014) --- crates/xlsx/src/chart.rs | 5 ++- crates/xlsx/src/tests/chart_combo_kinds.rs | 46 ++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 crates/xlsx/src/tests/chart_combo_kinds.rs diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..cbe8a1eb1 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -193,8 +193,11 @@ pub fn read_chart(root: &El) -> Option { for (gi, (g, k)) in groups.iter().enumerate() { for s in g.kids("ser") { let mut ser = read_series(s); - if gi > 0 { + if groups.len() > 1 { + // Every series of a combo chart keeps its own group's kind, the first group's included. ser.kind = Some(*k); + } + if gi > 0 { ser.secondary = axis_ids(g) != first_axes; } series.push(ser); diff --git a/crates/xlsx/src/tests/chart_combo_kinds.rs b/crates/xlsx/src/tests/chart_combo_kinds.rs new file mode 100644 index 000000000..6857683ad --- /dev/null +++ b/crates/xlsx/src/tests/chart_combo_kinds.rs @@ -0,0 +1,46 @@ +//! Importing a combination chart keeps each series' own group kind, the first group's included. + +use gridcraft_model::ChartKind; + +use crate::chart::read_chart; +use crate::xml::parse; + +fn ser(i: usize) -> String { + format!("Data!$A$1:$A$5") +} + +const AX: &str = ""; + +fn bar(sers: &str) -> String { + format!("{sers}{AX}") +} + +fn line(sers: &str) -> String { + format!("{sers}{AX}") +} + +fn kinds(first: &str, second: &str) -> Vec> { + let xml = format!( + "\ + {first}{second}" + ); + let root = parse(xml.as_bytes()).unwrap(); + let ch = read_chart(&root).unwrap(); + assert_eq!(ch.kind, ChartKind::Combo); + ch.series.iter().map(|s| s.kind).collect() +} + +#[test] +fn first_group_with_two_columns_stays_columns() { + let k = kinds(&bar(&(ser(0) + &ser(1))), &line(&ser(2))); + assert_eq!(k[0], Some(ChartKind::ColumnClustered)); + assert_eq!(k[1], Some(ChartKind::ColumnClustered)); + assert!(matches!(k[2], Some(ChartKind::Line | ChartKind::LineMarkers))); +} + +#[test] +fn line_first_group_stays_a_line() { + let k = kinds(&line(&ser(0)), &bar(&ser(1))); + assert!(matches!(k[0], Some(ChartKind::Line | ChartKind::LineMarkers))); + assert_eq!(k[1], Some(ChartKind::ColumnClustered)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..218cc632f 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -2,6 +2,7 @@ mod array_export; mod chart_bool_default; +mod chart_combo_kinds; mod csv_tests; mod encrypted_tests; mod fixtures; From 29c38887deeb748f32e14b482a15304f81777558 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:03:44 +0200 Subject: [PATCH 046/446] Keep chart cache point positions when a reference has blank edges (#1015) --- crates/xlsx/src/chart.rs | 9 ++++++-- crates/xlsx/src/tests/chart_ref_offsets.rs | 25 ++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 crates/xlsx/src/tests/chart_ref_offsets.rs diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..c93377ca6 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -292,9 +292,14 @@ pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option MAX_CACHE { return None; diff --git a/crates/xlsx/src/tests/chart_ref_offsets.rs b/crates/xlsx/src/tests/chart_ref_offsets.rs new file mode 100644 index 000000000..87e6ba9fa --- /dev/null +++ b/crates/xlsx/src/tests/chart_ref_offsets.rs @@ -0,0 +1,25 @@ +//! Chart caches keep the position of every point inside the referenced range. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Sheet, Workbook}; +use std::sync::Arc; + +use crate::chart::ref_values; + +fn book() -> Workbook { + let mut s = Sheet::new("Data"); + s.set_value(CellRef::new(3, 0), Value::Number(5.0)); + Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() } +} + +#[test] +fn explicit_range_keeps_leading_and_trailing_blanks() { + let vals = ref_values(&book(), 0, "Data!$A$3:$A$6").unwrap(); + assert_eq!(vals, vec![Value::Empty, Value::Number(5.0), Value::Empty, Value::Empty]); +} + +#[test] +fn whole_column_still_stops_at_used_range_and_starts_at_top() { + let vals = ref_values(&book(), 0, "Data!$A:$A").unwrap(); + assert_eq!(vals, vec![Value::Empty, Value::Empty, Value::Empty, Value::Number(5.0)]); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..afa2247de 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -2,6 +2,7 @@ mod array_export; mod chart_bool_default; +mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; mod fixtures; From 01032e9010cc68fa684cacc9140a354a0683fa4f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:01 +0200 Subject: [PATCH 047/446] Cap CSV import fields per record at the worksheet column limit (#1037) --- crates/xlsx/src/csv.rs | 19 ++++++++++++++----- crates/xlsx/src/tests/csv_tests.rs | 25 +++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 47486bfa2..946fafe18 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -87,8 +87,9 @@ pub fn sniff_delimiter(text: &str, quote: char) -> u8 { best.0 } -/// Splits CSV text into records of fields. -fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec) -> bool) { +/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are +/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field. +pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec) -> bool) { let mut field = String::new(); let mut record: Vec = vec![]; let mut in_q = false; @@ -112,13 +113,19 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut in_q = true; field_started_quoted = true; } else if c == delim { - record.push(std::mem::take(&mut field)); + let f = std::mem::take(&mut field); + if record.len() < MAX_COLS as usize { + record.push(f); + } field_started_quoted = false; } else if c == '\r' || c == '\n' { if c == '\r' && chars.peek() == Some(&'\n') { chars.next(); } - record.push(std::mem::take(&mut field)); + let f = std::mem::take(&mut field); + if record.len() < MAX_COLS as usize { + record.push(f); + } field_started_quoted = false; if !on_record(std::mem::take(&mut record)) { return; @@ -128,7 +135,9 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut } } if !field.is_empty() || !record.is_empty() || field_started_quoted { - record.push(field); + if record.len() < MAX_COLS as usize { + record.push(field); + } on_record(record); } } diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index cc52072e7..c0a774b0d 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -1,7 +1,7 @@ -use gridcraft_core::{CellRef, DateSystem, Value}; +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value}; use gridcraft_model::{Sheet, Workbook}; -use crate::csv::{decode_text, sniff_delimiter}; +use crate::csv::{decode_text, parse_records, sniff_delimiter}; use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { @@ -190,3 +190,24 @@ fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_posit let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); assert_eq!(tabs.len(), 14); } + +#[test] +fn parser_stops_collecting_fields_at_max_cols() { + let extra = 50; + // MAX_COLS + extra fields on the first row (the last one filled), then a short second row. + let mut text = ",".repeat((MAX_COLS + extra) as usize - 1); + text.push_str("end\nx,y\n"); + let mut lens = vec![]; + parse_records(&text, ',', '"', |rec| { + lens.push(rec.len()); + true + }); + assert_eq!(lens, vec![MAX_COLS as usize, 2]); + // An unterminated last row is capped the same way. + let mut lens = vec![]; + parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| { + lens.push(rec.len()); + true + }); + assert_eq!(lens, vec![MAX_COLS as usize]); +} From 8939e7817f8d81f42472a50551984bcbdfdc05ef Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:04:09 +0200 Subject: [PATCH 048/446] Fix xlsx: combine escaped UTF-16 surrogate pairs when decoding strings (#1025) --- crates/xlsx/src/xml.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 43bb5b53e..f27927638 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -304,6 +304,20 @@ fn looks_like_escape(chars: &[char], i: usize) -> bool { chars.get(i + 1) == Some(&'x') && chars.get(i + 6) == Some(&'_') && (i + 2..i + 6).all(|k| chars.get(k).is_some_and(|c| c.is_ascii_hexdigit())) } +fn escape_unit(chars: &[char], i: usize) -> Option { + let hex: String = chars.get(i + 2..i + 6)?.iter().collect(); + u32::from_str_radix(&hex, 16).ok() +} + +/// The character for a high surrogate escape at `i` (value `high`) followed directly by a low surrogate escape. +fn surrogate_pair(chars: &[char], i: usize, high: u32) -> Option { + if !(0xD800..=0xDBFF).contains(&high) || chars.get(i + 7) != Some(&'_') || !looks_like_escape(chars, i + 7) { + return None; + } + let low = escape_unit(chars, i + 7).filter(|l| (0xDC00..=0xDFFF).contains(l))?; + char::from_u32(0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)) +} + /// Inverse of [`encode_xstring`]. pub fn decode_xstring(s: &str) -> String { if !s.contains("_x") { @@ -314,12 +328,18 @@ pub fn decode_xstring(s: &str) -> String { let mut i = 0; while i < chars.len() { if chars[i] == '_' && looks_like_escape(&chars, i) { - let hex: String = chars[i + 2..i + 6].iter().collect(); - if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + let unit = escape_unit(&chars, i); + if let Some(ch) = unit.and_then(char::from_u32) { o.push(ch); i += 7; continue; } + // A high surrogate escape followed by a low surrogate escape is one UTF-16 pair. + if let Some(ch) = unit.and_then(|high| surrogate_pair(&chars, i, high)) { + o.push(ch); + i += 14; + continue; + } } o.push(chars[i]); i += 1; @@ -402,6 +422,19 @@ mod tests { assert_eq!(decode_xstring("_x00"), "_x00"); } + #[test] + fn xstring_surrogate_pairs() { + assert_eq!(decode_xstring("a_xD83D__xDE00_b"), "a\u{1F600}b"); + assert_eq!(decode_xstring("_xd83d__xde00_"), "\u{1F600}"); + // Lone surrogates stay as literal escape text. + assert_eq!(decode_xstring("_xD83D_"), "_xD83D_"); + assert_eq!(decode_xstring("_xDE00_"), "_xDE00_"); + assert_eq!(decode_xstring("_xD83D_x"), "_xD83D_x"); + assert_eq!(decode_xstring("_xD83D__x0041_"), "_xD83D_A"); + // A protected literal escape is not a low surrogate. + assert_eq!(decode_xstring("_xD83D__x005F_xDE00_"), "_xD83D__xDE00_"); + } + #[test] fn numbers() { assert_eq!(num(1.0), "1"); From 66be0a864637ffec667787e877db6634d5a579a4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:36 +0200 Subject: [PATCH 049/446] Open notice: do not promise to keep macros that were dropped (#1024) --- crates/ui-egui/src/lib.rs | 9 ++++++++- crates/ui-egui/tests/interaction.rs | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..06396f42b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -800,8 +800,15 @@ impl SheetApp { } let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); if !vba.is_empty() { + // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. + let dropped = vba.iter().any(|m| m.contains("dropped")); + let text = if dropped { + "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." + } else { + "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." + }; self.notice = Some(Notice { - text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(), + text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())), }); diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index b40769cf8..13f4aa3c5 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -781,6 +781,22 @@ fn opening_a_macro_workbook_shows_a_notice() { assert!(h.state().notice.is_none()); } +#[test] +fn dropped_macro_warning_never_promises_to_keep_macros() { + let mut h = harness(blank()); + for w in [ + "macros (VBA project) reference a missing part and were dropped", + "macros (VBA project) could not be read and were dropped: bad zip", + ] { + h.state_mut().show_open_warnings(&json!({"warnings": [w]})); + let n = h.state().notice.clone().unwrap(); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning); + assert!(n.text.contains("dropped") && !n.text.contains("keeps them"), "{}", n.text); + } + h.state_mut().show_open_warnings(&json!({"warnings": ["macros (VBA project) were kept but not executed"]})); + assert!(h.state().notice.clone().unwrap().text.contains("keeps them when you save as .xlsm")); +} + #[test] fn plain_open_warning_becomes_an_info_notice() { let mut h = harness(blank()); From 3580a72c0d26db7f73903838c0e12a7212ff28bf Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:01 +0200 Subject: [PATCH 050/446] Respect workbook structure protection in sheet rename, move, copy, hide and unhide (#1049) --- crates/engine/src/cmd/review.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/cmd/sheet.rs | 12 ++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index c55aac8e5..18363ead7 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -426,6 +426,32 @@ mod tests { s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap(); } + #[test] + fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() { + let mut s = book(); + let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::>(); + let before = names(&s); + s.execute("review.protectWorkbook", json!({})).unwrap(); + assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err()); + assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err()); + assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err()); + assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err()); + assert_eq!(names(&s), before); + assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible)); + s.execute("review.protectWorkbook", json!({})).unwrap(); + s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap(); + s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap(); + s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); + s.execute("sheet.hide", json!({"sheet": 1})).unwrap(); + s.execute("review.protectWorkbook", json!({})).unwrap(); + assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err()); + assert!(s.execute("sheet.unhide", json!({})).is_err()); + assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden); + s.execute("review.protectWorkbook", json!({})).unwrap(); + s.execute("sheet.unhide", json!({"sheet": 1})).unwrap(); + } + #[test] fn protect_workbook_with_a_password_really_verifies() { let mut s = book(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e..da6adb4ba 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -767,6 +767,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { } fn rename_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } let i = target_sheet(s, p)?; let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { return Err(bad("sheet.rename", "missing `name`")) }; s.doc()?.wb.check_sheet_name(&name, Some(i)).map_err(EngineError::Other)?; @@ -818,6 +821,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { } fn move_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } let i = target_sheet(s, p)?; let copy = bool_param(p, "copy").unwrap_or(false); let to = p.get("to").and_then(Json::as_u64).map(|v| v as usize).ok_or_else(|| bad("sheet.move", "missing `to`"))?; @@ -895,6 +901,9 @@ fn tab_color(s: &mut Session, p: &Json) -> Result { } fn hide_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } let i = target_sheet(s, p)?; if s.doc()?.wb.sheets.iter().filter(|s| s.visibility == Visibility::Visible).count() <= 1 { return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into())); @@ -909,6 +918,9 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result { } fn unhide_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } if p.get("sheet").is_none() { let hidden: Vec = s.doc()?.wb.sheets.iter().filter(|s| s.visibility != Visibility::Visible).map(|s| s.name.clone()).collect(); if hidden.is_empty() { From 2c8ff394cc51f78276eb76f2535cacc0d55e3d14 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:03:11 +0200 Subject: [PATCH 051/446] Refuse a cut-paste whose destination does not fit instead of dropping cells (#997) --- crates/engine/src/cmd/edit.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..261a11101 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -936,6 +936,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let src_wb = clip.wb.clone(); let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; let src_sheet_name = src_sheet.name.clone(); + if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { + // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. + return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); + } edit(s, |cx| { // Styles from another workbook must be re-interned. let mut style_map = std::collections::HashMap::new(); @@ -1675,6 +1679,28 @@ mod tests { assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); } + #[test] + fn cut_paste_that_overflows_the_grid_moves_nothing() { + // #997: the source was emptied first and the cells that fell off the grid were skipped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + // Transposed, the 2 rows become 2 columns and the second one is past XFD. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed, the second row would be past the last row. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed it still fits in column XFD. + s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); + assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); + } + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); From a091fd9b509e317308a12c84bce0a50acd3af61f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:04:20 +0200 Subject: [PATCH 052/446] CSV export: check the size cap before each field, not after the row (#1023) --- crates/xlsx/src/csv.rs | 22 +++++++++++++++++----- crates/xlsx/src/tests/csv_tests.rs | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 47486bfa2..485de7a37 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -193,9 +193,9 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { /// /// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the /// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas). -/// An export that would pass the cap is refused before anything is allocated, or as soon as the -/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in -/// its place. +/// An export that would pass the cap is refused before anything is allocated, or at the first +/// field or line end that takes it past the cap (a field is measured before it is written), with +/// [`IoError::TooLarge`]; accepted exports keep every field in its place. pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text) } @@ -221,9 +221,21 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te out.push(delim); } let t = cell_text(CellRef::new(row, col)); - if t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r') { + let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r'); + // The exact size of the field as written (the delimiter was pushed above), so a row + // never grows past the cap unseen and no doubled-quote copy of the text is built. + let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() }; + if out.len().saturating_add(field_len) > limit { + return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1))); + } + if quoted { out.push('"'); - out.push_str(&t.replace('"', "\"\"")); + for ch in t.chars() { + if ch == '"' { + out.push('"'); + } + out.push(ch); + } out.push('"'); } else { out.push_str(&t); diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index cc52072e7..3716a162d 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -186,6 +186,25 @@ fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_posit let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err(); assert!(err.to_string().contains("stopped at row"), "{err}"); assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok()); + // Within one row the cap is checked before each field is written: a wide row of long and + // quote-heavy fields is refused at the field that crosses it, without asking for the rest. + let mut wide = Sheet::new("W"); + for c in 0..10u32 { + wide.set_value(CellRef::new(0, c), Value::text("\"".repeat(100))); + } + let mut asked = 0; + let err = write_csv_limited(&wide, b',', 500, |c| { + asked += 1; + text_of(&wide)(c) + }) + .unwrap_err(); + assert!(err.to_string().contains("stopped at row 1"), "{err}"); + assert_eq!(asked, 3, "each field is 202 bytes; the third passes 500"); + // Exact boundary: a quoted field of 202 bytes plus CRLF fits in 204 and not in 203. + let mut one = Sheet::new("O"); + one.set_value(CellRef::new(0, 0), Value::text("\"".repeat(100))); + assert_eq!(write_csv_limited(&one, b',', 204, text_of(&one)).unwrap().len(), 204); + assert!(write_csv_limited(&one, b',', 203, text_of(&one)).is_err()); // A tab is one byte, like a comma. let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); assert_eq!(tabs.len(), 14); From e440c8bdc91cc9909551304a62359c409d905ca6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:05:32 +0200 Subject: [PATCH 053/446] Read date text arguments in the workbook date system (#1038) --- crates/functions/src/date.rs | 88 +++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 67c56999e..006bc1148 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -13,16 +13,16 @@ const ND: &[bool] = &[true, true, false]; const NDI: &[bool] = &[true, true, true, false]; /// A date serial argument (truncated to whole days when `whole`). Out of range → `#NUM!`. -fn serial_arg(a: &[Arg], i: usize) -> R { - let n = num(a, i)?; +fn serial_arg(a: &[Arg], i: usize, sys: DateSystem) -> R { + let n = scalar(arg(a, i)?).to_number_in(sys)?; if !(0.0..MAX_SERIAL + 1.0).contains(&n) { return Err(CellError::Num); } Ok(n) } -fn day_arg(a: &[Arg], i: usize) -> R { - Ok(serial_arg(a, i)?.floor() as i64) +fn day_arg(a: &[Arg], i: usize, sys: DateSystem) -> R { + Ok(serial_arg(a, i, sys)?.floor() as i64) } fn dt(sys: DateSystem, serial: f64) -> R { @@ -66,8 +66,9 @@ fn time(a: &[Arg], _c: &mut dyn Ctx) -> R { } fn part(a: &[Arg], c: &mut dyn Ctx, f: fn(&DateTime) -> f64) -> R { - let s = serial_arg(a, 0)?; - num_val(f(&dt(c.date_system(), s.floor())?)) + let sys = c.date_system(); + let s = serial_arg(a, 0, sys)?; + num_val(f(&dt(sys, s.floor())?)) } fn year(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -81,20 +82,20 @@ fn day(a: &[Arg], c: &mut dyn Ctx) -> R { } /// Seconds into the day, rounded to the nearest second. -fn day_seconds(a: &[Arg]) -> R { - let s = serial_arg(a, 0)?; +fn day_seconds(a: &[Arg], sys: DateSystem) -> R { + let s = serial_arg(a, 0, sys)?; let secs = ((s - s.floor()) * 86400.0).round() as i64; Ok(if secs >= 86400 { 0 } else { secs }) } -fn hour(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_seconds(a)? / 3600) as f64) +fn hour(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val((day_seconds(a, c.date_system())? / 3600) as f64) } -fn minute(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_seconds(a)? / 60 % 60) as f64) +fn minute(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val((day_seconds(a, c.date_system())? / 60 % 60) as f64) } -fn second(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_seconds(a)? % 60) as f64) +fn second(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val((day_seconds(a, c.date_system())? % 60) as f64) } fn now(_a: &[Arg], c: &mut dyn Ctx) -> R { @@ -105,9 +106,10 @@ fn today(_a: &[Arg], c: &mut dyn Ctx) -> R { } fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R { - let s = day_arg(a, 0)?; + let sys = c.date_system(); + let s = day_arg(a, 0, sys)?; let t = to_int(opt_num(a, 1, 1.0)?)?; - let wd = weekday0(c.date_system(), s) as i64; + let wd = weekday0(sys, s) as i64; let r = match t { 1 | 17 => wd + 1, 2 | 11 => (wd + 6) % 7 + 1, @@ -128,7 +130,7 @@ fn isoweek(sys: DateSystem, s: i64) -> R { fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let s = day_arg(a, 0)?; + let s = day_arg(a, 0, sys)?; let t = to_int(opt_num(a, 1, 1.0)?)?; let start = match t { 1 | 17 => 0, @@ -144,7 +146,8 @@ fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R { } fn isoweeknum(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(isoweek(c.date_system(), day_arg(a, 0)?)? as f64) + let sys = c.date_system(); + num_val(isoweek(sys, day_arg(a, 0, sys)?)? as f64) } fn months_arg(a: &[Arg], i: usize) -> R { @@ -157,7 +160,7 @@ fn months_arg(a: &[Arg], i: usize) -> R { fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let d = dt(sys, day_arg(a, 0)? as f64)?; + let d = dt(sys, day_arg(a, 0, sys)? as f64)?; let m = months_arg(a, 1)?; let total = d.year as i64 * 12 + d.month as i64 - 1 + m; let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1); @@ -172,7 +175,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let d = dt(sys, day_arg(a, 0)? as f64)?; + let d = dt(sys, day_arg(a, 0, sys)? as f64)?; let m = months_arg(a, 1)?; let total = d.year as i64 * 12 + d.month as i64 - 1 + m; let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1); @@ -185,8 +188,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let s = day_arg(a, 0)?; - let e = day_arg(a, 1)?; + let s = day_arg(a, 0, sys)?; + let e = day_arg(a, 1, sys)?; let unit = text(a, 2)?.to_ascii_uppercase(); if s > e { return Err(CellError::Num); @@ -247,8 +250,9 @@ fn timevalue(a: &[Arg], c: &mut dyn Ctx) -> R { num_val(n - n.floor()) } -fn days(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_arg(a, 0)? - day_arg(a, 1)?) as f64) +fn days(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + num_val((day_arg(a, 0, sys)? - day_arg(a, 1, sys)?) as f64) } fn last_of_feb(d: &DateTime) -> bool { @@ -257,8 +261,8 @@ fn last_of_feb(d: &DateTime) -> bool { fn days360(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let d1 = dt(sys, day_arg(a, 0)? as f64)?; - let d2 = dt(sys, day_arg(a, 1)? as f64)?; + let d1 = dt(sys, day_arg(a, 0, sys)? as f64)?; + let d2 = dt(sys, day_arg(a, 1, sys)? as f64)?; let european = opt_bool(a, 2, false)?; num_val(days360_core(&d1, &d2, european) as f64) } @@ -313,7 +317,7 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> { } } -fn holidays(v: Option<&Arg>) -> R> { +fn holidays(v: Option<&Arg>, sys: DateSystem) -> R> { let mut out = Vec::new(); if let Some(a) = v { for x in as_array(&a.value).iter() { @@ -321,7 +325,7 @@ fn holidays(v: Option<&Arg>) -> R> { Value::Empty => {} Value::Error(e) => return Err(*e), other => { - let n = other.to_number()?; + let n = other.to_number_in(sys)?; if !(0.0..MAX_SERIAL + 1.0).contains(&n) { return Err(CellError::Num); } @@ -353,9 +357,9 @@ fn count_workdays(sys: DateSystem, a: i64, b: i64, mask: &[bool; 7]) -> i64 { fn networkdays_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R { let sys = c.date_system(); - let s = day_arg(a, 0)?; - let e = day_arg(a, 1)?; - let hs = holidays(hol)?; + let s = day_arg(a, 0, sys)?; + let e = day_arg(a, 1, sys)?; + let hs = holidays(hol, sys)?; let (lo, hi, sign) = if s <= e { (s, e, 1) } else { (e, s, -1) }; let mut n = count_workdays(sys, lo, hi, &mask); n -= hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64; @@ -387,13 +391,13 @@ fn advance(sys: DateSystem, start: i64, n: i64, dir: i64, mask: &[bool; 7], per_ fn workday_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R { let sys = c.date_system(); - let start = day_arg(a, 0)?; + let start = day_arg(a, 0, sys)?; let days = num(a, 1)?.trunc(); if days.abs() > 1e7 { return Err(CellError::Num); } let days = days as i64; - let hs = holidays(hol)?; + let hs = holidays(hol, sys)?; let per_week = mask.iter().filter(|w| !**w).count() as i64; if per_week == 0 { return Err(CellError::Value); @@ -476,10 +480,11 @@ fn feb29_between(sys: DateSystem, s: i64, e: i64, d1: &DateTime, d2: &DateTime) } fn yearfrac(a: &[Arg], c: &mut dyn Ctx) -> R { - let s = day_arg(a, 0)?; - let e = day_arg(a, 1)?; + let sys = c.date_system(); + let s = day_arg(a, 0, sys)?; + let e = day_arg(a, 1, sys)?; let basis = if has(a, 2) { to_int(num(a, 2)?)? } else { 0 }; - num_val(yearfrac_core(c.date_system(), s, e, basis)?) + num_val(yearfrac_core(sys, s, e, basis)?) } pub(crate) fn specs() -> Vec { @@ -832,4 +837,15 @@ mod tests { close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0); is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value); } + + #[test] + fn date_text_arguments_use_the_workbook_date_system() { + let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() }; + close(crate::call(crate::lookup("YEAR").unwrap(), &[t("1/1/2020")], &mut ctx), 2020.0); + close(crate::call(crate::lookup("MONTH").unwrap(), &[t("2/29/1904")], &mut ctx), 2.0); + close(crate::call(crate::lookup("DAY").unwrap(), &[t("2/29/1904")], &mut ctx), 29.0); + close(crate::call(crate::lookup("DAYS").unwrap(), &[t("1/2/2020"), t("1/1/2020")], &mut ctx), 1.0); + let networkdays = crate::lookup("NETWORKDAYS").unwrap(); + close(crate::call(networkdays, &[t("1/6/2020"), t("1/10/2020"), t("1/8/2020")], &mut ctx), 4.0); + } } From 71437f23b1556f404ae9ae8975fb40080c712c98 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:03:08 +0200 Subject: [PATCH 054/446] Fix table without header: re-point references when the data moves down (#1030) --- crates/engine/src/cmd/insert.rs | 41 +++++++++++++++++++++++++++------ crates/engine/src/cmd/sheet.rs | 4 ++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index d878e4469..b195a4954 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -2,9 +2,11 @@ //! conditional formatting (Home › Styles) and symbols. use gridcraft_core::{CellRef, MAX_ROWS, RangeRef}; +use gridcraft_formula::adjust::{Axis, Edit}; use gridcraft_model::*; use serde_json::{Value as Json, json}; +use super::sheet::{rewrite, shift_cell_features}; use super::*; use crate::selection::current_region; @@ -276,13 +278,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into())); } sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1); - sh.cell_pictures = std::mem::take(&mut sh.cell_pictures) - .into_iter() - .filter_map(|(c, v)| { - let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) }; - dest.map(|c| (c, v)) - }) - .collect(); + // Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula + // reference (on any sheet) at the cells that moved. + let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) }; + shift_cell_features(sh, &e); + let name = sh.name.clone(); + rewrite(&mut cx.wb, &name, &e); + let sh = cx.sheet_mut(sheet)?; for (i, c) in (r.start.col..=r.end.col).enumerate() { sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1))); } @@ -1553,4 +1555,29 @@ mod tests { let rules = s.execute("home.manageRules", json!({})).unwrap(); assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0)); } + + #[test] + fn table_without_header_keeps_outside_references() { + // #1030: the data moved down for the new header row, but formulas outside kept the old addresses. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap(); + s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], "Column1"); + assert_eq!(get(&mut s, "A2")["value"], json!(10.0)); + assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)"); + assert_eq!(get(&mut s, "B1")["value"], json!(30.0)); + assert_eq!(get(&mut s, "C1")["formula"], "=A3"); + assert_eq!(get(&mut s, "C1")["value"], json!(20.0)); + let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap(); + assert_eq!(other["formula"], "=Sheet1!A3"); + assert_eq!(other["value"], json!(20.0)); + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e..d5d817f41 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -214,7 +214,7 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: } /// Rewrites every formula reference (and chart series) for a structural edit on `target`. -fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { +pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { super::edit::rewrite_all_formulas(wb, target, e); for si in 0..wb.sheets.len() { let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); @@ -963,7 +963,7 @@ pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option { /// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional /// format, validation, table, filter and print-area ranges, notes, links and sparklines. -fn shift_cell_features(sh: &mut Sheet, e: &Edit) { +pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) { let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start); sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect(); for cf in sh.cond_formats.iter_mut() { From 2effccc26c40551e4bf6f006087f48b5bcecead6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:04:21 +0200 Subject: [PATCH 055/446] fix(ui): commit a pointed formula on the sheet it was started on (#996) --- crates/ui-egui/src/lib.rs | 9 ++++ crates/ui-egui/src/tabs.rs | 27 ++++++++-- crates/ui-egui/tests/commit_after_pointing.rs | 54 +++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/commit_after_pointing.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..580378925 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -929,6 +929,15 @@ impl SheetApp { } self.toast = Some((msg, now_ms())); } + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + if self.session.active().is_some_and(|d| d.wb.active_sheet != ed.sheet) + && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } let r = if fill_selection { self.session.run("range.fill", json!({"input": text})) } else { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..8a20f4033 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -28,8 +28,8 @@ fn activate_sheet(app: &mut SheetApp, i: usize) { if app.editor.as_ref().is_some_and(|e| e.can_point()) { let _ = app.session.run("sheet.activate", json!({"sheet": i})); } else { - if app.editor.is_some() { - app.commit_edit(0, 0, false, false); + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; } app.run_or_alert("sheet.activate", json!({"sheet": i})); } @@ -340,8 +340,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::{navigator_items, rename_needs_focus}; + use super::{activate_sheet, navigator_items, rename_needs_focus}; use gridcraft_engine::model::{Sheet, Visibility}; + use serde_json::json; use std::sync::Arc; #[test] @@ -360,4 +361,24 @@ mod tests { let sheets = [Sheet::new("Data"), hidden, very_hidden, Sheet::new("Summary")].map(Arc::new); assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]); } + + #[test] + fn a_rejected_commit_keeps_the_sheet_and_the_edit() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let rule = json!({"range": "A1", "type": "whole", "operator": "between", "formula1": "1", "formula2": "10"}); + session.execute("data.validation", rule).unwrap(); + let mut app = crate::SheetApp::new(session, Default::default()); + app.begin_edit(Some("99".into()), false); + activate_sheet(&mut app, 1); + assert_eq!(app.session.active().unwrap().wb.active_sheet, 0, "a value the validation stops must not leave its sheet"); + assert!(app.editor.is_some(), "the edit stays open to be corrected"); + app.editor.as_mut().unwrap().text = "7".into(); + activate_sheet(&mut app, 1); + assert_eq!(app.session.active().unwrap().wb.active_sheet, 1, "the corrected value commits and the sheet changes"); + let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap(); + assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet"); + } } diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/commit_after_pointing.rs new file mode 100644 index 000000000..89e1ccd29 --- /dev/null +++ b/crates/ui-egui/tests/commit_after_pointing.rs @@ -0,0 +1,54 @@ +//! A formula started on one sheet and pointed at another sheet lands on the sheet it started on. + +use egui::vec2; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +/// Two sheets, `range` selected on the first, and `=1+2` typed there; then pointing moves to the second sheet. +fn pointing_on_second_sheet(range: &str) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + session.execute("selection.set", json!({"range": range})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().begin_edit(Some("=1+2".into()), false); + h.run_steps(2); + // Pointing moves to the other sheet without finishing the edit. + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h +} + +#[test] +fn commit_after_pointing_on_another_sheet_writes_to_the_origin_sheet() { + let mut h = pointing_on_second_sheet("F8"); + assert!(h.state_mut().commit_edit(0, 0, false, false)); + let at = CellRef::parse("F8").unwrap(); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "formula written on the origin sheet"); + assert!(wb.sheet(1).unwrap().cell(at).is_none(), "the pointed-at sheet is untouched"); +} + +#[test] +fn ctrl_enter_after_pointing_on_another_sheet_fills_the_origin_selection() { + let mut h = pointing_on_second_sheet("B2:C3"); + assert!(h.state_mut().commit_edit(0, 0, false, true)); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + for a1 in ["B2", "C2", "B3", "C3"] { + let at = CellRef::parse(a1).unwrap(); + assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "{a1} filled on the origin sheet"); + assert!(wb.sheet(1).unwrap().cell(at).is_none(), "{a1} on the pointed-at sheet is untouched"); + } + assert!(wb.sheet(1).unwrap().cell(CellRef::parse("A1").unwrap()).is_none(), "the pointed-at sheet's selection is untouched"); +} From a0b96d99200ef2dce65b5917f3926118b54da693 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:01:53 +0200 Subject: [PATCH 056/446] Size the list validation dropdown to up to 8 rows (#1089) --- crates/ui-egui/src/grid.rs | 36 +++++++++++++++++++++-- crates/ui-egui/tests/popup_dismissal.rs | 39 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..f2ac39b64 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3069,6 +3069,18 @@ pub(crate) fn paint_overlays( let _ = (wb, si); } +/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). +const LIST_PICKER_MAX_ROWS: usize = 8; +const LIST_PICKER_ROW_H: f32 = 18.0; +/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. +const LIST_PICKER_CHROME: f32 = 8.0; + +/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. +fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { + let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; + rows * row_h + (rows - 1.0) * gap +} + fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; @@ -3081,14 +3093,24 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) }; let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); let r = geo.cell_rect(sh, c); - let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. + let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); + // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). + let screen = ui.ctx().content_rect(); + let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); + let (at, pivot) = match (above, geo.rtl) { + (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), + (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), + (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), + (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), + }; let mut close = false; let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); - egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { for it in &items { - if ui.add(egui::Button::new(it.as_str()).frame(false).min_size(vec2(r.width().max(100.0), 18.0))).clicked() { + if ui.add(egui::Button::new(it.as_str()).frame(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))).clicked() { app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); close = true; } @@ -3321,6 +3343,14 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn list_picker_fits_up_to_eight_rows_then_scrolls() { + assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); + assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); + assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); + assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); + } + #[test] fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { for (from, zoom_in, to) in [ diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/popup_dismissal.rs index 391ccd6af..24d9fafbc 100644 --- a/crates/ui-egui/tests/popup_dismissal.rs +++ b/crates/ui-egui/tests/popup_dismissal.rs @@ -174,3 +174,42 @@ fn worksheet_popups_close_on_escape_or_window_focus_loss() { } } } + +/// Gives `cell` a list validation of `count` items, opens its dropdown and returns the popup's rectangle. +fn open_list_of(h: &mut Harness, cell: CellRef, count: usize) -> egui::Rect { + let items: Vec = (1..=count).map(|i| format!("Item {i}")).collect(); + let formula1 = format!("\"{}\"", items.join(",")); + h.state_mut().session.execute("data.validation", json!({"range": cell.a1(), "type": "list", "formula1": formula1})).unwrap(); + h.state_mut().run("selection.set", json!({"cell": cell.a1()})).unwrap(); + h.run_steps(2); + let geometry = geo(h); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + let arrow = grid::validation_arrow(sh, &geometry, cell).unwrap().center(); + click(h, arrow, PointerButton::Primary); + assert!(is_open(h, Menu::Validation)); + h.ctx.memory(|m| m.area_rect(egui::Id::new("dv_list"))).unwrap() +} + +#[test] +fn validation_dropdown_fits_up_to_eight_items_then_scrolls() { + // Each item row is 18 px high; the popup used to collapse to egui's 64 px scroll minimum (about three items). + for (count, min_h, max_h) in [(5, 5.0 * 18.0, 12.0 * 18.0), (12, 8.0 * 18.0, 12.0 * 18.0)] { + let mut h = harness(); + let popup = open_list_of(&mut h, CellRef::parse("E5").unwrap(), count); + assert!(popup.height() >= min_h && popup.height() < max_h, "{count} items: popup is {} px high", popup.height()); + } +} + +#[test] +fn validation_dropdown_opens_above_a_cell_near_the_bottom_of_the_window() { + let mut h = harness(); + let bottom = h.state().grid.rect.unwrap().bottom(); + let geometry = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + // The last row of column E that is fully visible above the bottom of the grid. + let visible = |row: u32| CellRef::parse(&format!("E{row}")).filter(|&c| geometry.cell_rect(sh, c).bottom() <= bottom); + let cell = (1..200).map_while(visible).last().unwrap(); + let popup = open_list_of(&mut h, cell, 8); + let r = geo(&h).cell_rect(h.state().session.active().unwrap().wb.active().unwrap(), cell); + assert!(popup.bottom() <= r.top() + 0.5, "popup {popup:?} covers the cell {r:?}"); +} From 1539500b7fa0b3b46f96194e6a2823bcdb8e2df2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 20:11:02 +0200 Subject: [PATCH 057/446] Conditional formats: statistics only for rules that read them, borrowed not cloned per cell (#1098) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cf.rs | 179 +++++++++++++++++++++++++++++++++++----- 2 files changed, 161 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..516a54f90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (conditional formatting no longer scans its whole range for rules that read no statistics) · **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. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index b71ddb8ca..1b0a1fda7 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -34,7 +34,43 @@ struct RuleStats { counts: HashMap, } -fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec, HashMap) { +/// Which statistics a rule reads. Scanning the rule's whole range for them costs O(range), so +/// rules that read none (cell value, formula, text, blanks, errors, dates) never scan. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum StatsNeed { + /// Sorted numbers, their mean and deviation (top/bottom N, above average, scales, bars, icons). + Numbers, + /// How often each displayed text occurs (duplicate / unique values). + Counts, +} + +fn stats_need(rule: &CfRule) -> Option { + match rule { + CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts), + CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => { + Some(StatsNeed::Numbers) + } + _ => None, + } +} + +impl RuleStats { + fn build(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> RuleStats { + let (sorted, counts) = numbers_in(sh, ranges, need); + let n = sorted.len().max(1) as f64; + let avg = sorted.iter().sum::() / n; + let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::() / n).sqrt(); + RuleStats { sorted, avg, std, counts } + } +} + +#[cfg(test)] +thread_local! { + /// Cells `numbers_in` visited on this thread (tests count the work a lookup does). + static SCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec, HashMap) { let mut nums = Vec::new(); let mut counts = HashMap::new(); for r in ranges { @@ -43,12 +79,20 @@ fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec, HashMap continue, }; for c in r.iter().take(2_000_000) { + #[cfg(test)] + SCANNED.with(|n| n.set(n.get() + 1)); let v = sh.value(c); - if let Value::Number(n) = v { - nums.push(n); - } - if !v.is_empty() { - *counts.entry(v.display().to_lowercase()).or_insert(0) += 1; + match need { + StatsNeed::Numbers => { + if let Value::Number(n) = v { + nums.push(n); + } + } + StatsNeed::Counts => { + if !v.is_empty() { + *counts.entry(v.display().to_lowercase()).or_insert(0) += 1; + } + } } } } @@ -109,19 +153,13 @@ impl CfCache { sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect(); rules.sort_by_key(|(_, cf)| cf.priority); let v = sh.value(c); + let no_stats = RuleStats::default(); for (idx, cf) in rules { let first = cf.ranges.first().map(|r| r.start).unwrap_or(c); - let st = self - .stats - .entry(idx) - .or_insert_with(|| { - let (sorted, counts) = numbers_in(sh, &cf.ranges); - let n = sorted.len().max(1) as f64; - let avg = sorted.iter().sum::() / n; - let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::() / n).sqrt(); - RuleStats { sorted, avg, std, counts } - }) - .clone(); + let st: &RuleStats = match stats_need(&cf.rule) { + Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)), + None => &no_stats, + }; let text = v.display(); let matched_style: Option<&Style> = match &cf.rule { CfRule::CellIs { op, a, b, style } => { @@ -218,7 +256,7 @@ impl CfCache { if let Some(n) = v.as_f64() { let pts: Vec<(f64, [u8; 3])> = stops .iter() - .map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255]))) + .map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255]))) .collect(); let rgb = match pts.as_slice() { [(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }), @@ -240,8 +278,8 @@ impl CfCache { } CfRule::DataBar { min, max, color, gradient, show_value } => { if let Some(n) = v.as_f64() { - let lo = threshold(wb, sheet, c, &st, min); - let hi = threshold(wb, sheet, c, &st, max); + let lo = threshold(wb, sheet, c, st, min); + let hi = threshold(wb, sheet, c, st, max); let lo = if lo > 0.0 { 0.0 } else { lo }; let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 }; if out.bar.is_none() { @@ -270,7 +308,7 @@ impl CfCache { defaults.get(1..).unwrap_or_default() } }; - let cuts: Vec = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect(); + let cuts: Vec = lower.iter().map(|t| threshold(wb, sheet, c, st, t)).collect(); let mut idx = cuts.iter().filter(|cut| n >= **cut).count(); if *reverse { idx = k - 1 - idx.min(k - 1); @@ -397,6 +435,105 @@ mod tests { assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]); } + /// The statistics as every rule used to get them: one pass collecting both parts. + fn all_stats_before(sh: &Sheet, ranges: &[RangeRef]) -> RuleStats { + let mut nums = Vec::new(); + let mut counts = HashMap::new(); + for r in ranges { + let Some(r) = sh.used_range().and_then(|u| r.intersection(&u)) else { continue }; + for c in r.iter() { + let v = sh.value(c); + if let Value::Number(n) = v { + nums.push(n); + } + if !v.is_empty() { + *counts.entry(v.display().to_lowercase()).or_insert(0) += 1; + } + } + } + nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = nums.len().max(1) as f64; + let avg = nums.iter().sum::() / n; + let std = (nums.iter().map(|x| (x - avg).powi(2)).sum::() / n).sqrt(); + RuleStats { sorted: nums, avg, std, counts } + } + + #[test] + fn needed_statistics_equal_the_full_ones() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[3, "x"], [7, "X"], [3, null], ["=NA()", 2.5], [" ", 9], [-1, "y"]]})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.sheet(0).unwrap(); + for ranges in ["A1:B6", "A1:A3", "B2:B9", "D1:E4", "A1:A2"].map(|r| vec![RangeRef::parse(r).unwrap()]) { + let full = all_stats_before(sh, &ranges); + let numbers = RuleStats::build(sh, &ranges, StatsNeed::Numbers); + assert_eq!((numbers.sorted, numbers.avg.to_bits(), numbers.std.to_bits()), (full.sorted, full.avg.to_bits(), full.std.to_bits())); + assert_eq!(RuleStats::build(sh, &ranges, StatsNeed::Counts).counts, full.counts); + } + } + + #[test] + fn rules_that_read_statistics_still_match() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, "a"], [2, "A"], [3, "b"], [10, "c"], [2, null]]})).unwrap(); + let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r}); + s.execute("home.conditionalFormat", rule("B1:B5", json!({"type": "duplicate", "preset": "lightRedFill"}))).unwrap(); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "top10", "rank": 1, "preset": "redBorder"}))).unwrap(); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "colorScale"}))).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()); + let fill = |l: Option| l.and_then(|l| l.style).is_some_and(|st| st.fill.pattern == PatternType::Solid); + // Duplicates are case-insensitive texts: "a" and "A". + assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]); + // The top value gets the border, the scale runs from the minimum to the maximum. + assert!(look("A4").and_then(|l| l.style).is_some_and(|st| !st.borders.top.is_none())); + assert!(look("A1").and_then(|l| l.style).is_none_or(|st| st.borders.top.is_none())); + assert_eq!(look("A1").and_then(|l| l.scale_fill), Some([0xF8, 0x69, 0x6B])); + assert_eq!(look("A4").and_then(|l| l.scale_fill), Some([0x63, 0xBE, 0x7B])); + } + + /// Looking cells up does work in proportion to the cells asked for, not the rule's range: the + /// statistics are only gathered for rules that read them, and once per rule. + #[test] + fn lookups_do_not_scan_the_range_for_rules_without_statistics() { + let mut s = crate::Session::new(); + s.new_workbook(); + let rows: Vec = (1..=10_000).map(|i| json!([i, i % 7])).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute( + "home.conditionalFormat", + json!({"range": "A1:B10000", "rule": {"type": "expression", "formula": "=MOD(ROW(),2)=0", "preset": "lightRedFill"}}), + ) + .unwrap(); + s.execute( + "home.conditionalFormat", + json!({"range": "A1:B10000", "rule": {"type": "cellIs", "operator": "greater", "value": "5", "preset": "redBorder"}}), + ) + .unwrap(); + s.execute("home.conditionalFormat", json!({"range": "A1:B10000", "rule": {"type": "noBlanks", "preset": "redBorder"}})).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + SCANNED.with(|n| n.set(0)); + for r in 1..=50 { + for col in ["A", "B"] { + cache.look(&d.wb, 0, CellRef::parse(&format!("{col}{r}")).unwrap()); + } + } + assert_eq!(SCANNED.with(|n| n.get()), 0); + // A rule that does read them scans its range once, however many cells are looked up. + s.execute("home.conditionalFormat", json!({"range": "A1:A10000", "rule": {"type": "top10", "preset": "redBorder"}})).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + SCANNED.with(|n| n.set(0)); + for r in 1..=50 { + cache.look(&d.wb, 0, CellRef::parse(&format!("A{r}")).unwrap()); + } + assert_eq!(SCANNED.with(|n| n.get()), 10_000); + } + #[test] fn cell_value_operator_names() { let mut s = crate::Session::new(); From 568dd5826479d1adc33239dab1e0cd7c3245f8dd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:13:05 +0200 Subject: [PATCH 058/446] Charts: a huge trendline forecast no longer collapses the chart (#1148) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/chart/src/render.rs | 17 +++++++++--- crates/chart/src/tests.rs | 21 +++++++++++++++ crates/engine/src/cmd/extra.rs | 2 +- crates/model/src/features.rs | 28 ++++++++++++++++++++ crates/xlsx/src/chart.rs | 4 +-- crates/xlsx/src/tests/trendline_roundtrip.rs | 17 ++++++++++++ 7 files changed, 83 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..1d4ce08d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148) | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 037b9099e..ea77f226b 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -658,6 +658,13 @@ fn forecast(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } } +/// Most empty category slots a forward forecast may add after `ncat` categories: four times the +/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every +/// column to nothing. +fn max_forward_slots(ncat: usize) -> f64 { + (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 +} + /// Longest forward forecast among `s`'s regression trendlines. fn max_forward(s: &SeriesData) -> f64 { s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) @@ -748,6 +755,7 @@ fn draw_trendlines( frac: &dyn Fn(f64, f64) -> (f64, f64), px: &dyn Fn((f64, f64)) -> [f32; 2], labels: &mut Vec, + window: (f64, f64), ) { let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; for t in s.trendlines.iter().take(MAX_TRENDLINES) { @@ -756,7 +764,8 @@ fn draw_trendlines( TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), _ => { let Some(fit) = fit_trendline(spec, pts) else { continue }; - let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward)); + // Sample only the part of the forecast that is on the plot. + let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); if spec.display_equation { labels.push(fit.equation()); } @@ -936,7 +945,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) .map(|(_, s)| max_forward(s)) .fold(0.0, f64::max); - let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat)); + let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines); if !plot.ok() { @@ -1102,7 +1111,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] } }; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels); + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); } draw_trend_labels(ctx, plot, &trend_labels); } @@ -1196,7 +1205,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels); + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); } draw_trend_labels(ctx, plot, &trend_labels); } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 01f1aac49..a95368424 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1057,3 +1057,24 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { assert!(first_label > last_poly, "{kind:?}: label drawn under a later area"); } } + +#[test] +fn a_huge_trendline_forecast_does_not_collapse_the_columns() { + let m = ApproxMeasure; + let tc: Rgba = [1, 2, 3, 255]; + let col: Rgba = [10, 20, 30, 255]; + let mut s = series("Sales", &[1.0, 3.0, 2.0, 5.0, 4.0], col, ChartKind::ColumnClustered); + s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1e6, backward: 1e6, ..Trendline::default() }, color: tc }]; + let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d", "e"].map(String::from).to_vec(), series: vec![s] }; + let mut c = chart(ChartKind::ColumnClustered); + c.legend = LegendPos::None; + let prims = render(&c, &d, 600.0, 400.0, &m); + assert_sane(&prims, 600.0, 400.0); + let widths: Vec = + prims.iter().filter_map(|p| if let Prim::Rect { w, fill: Some(f), .. } = p { (*f == col).then_some(*w) } else { None }).collect(); + assert_eq!(widths.len(), 5); + assert!(widths.iter().all(|w| *w > 4.0), "columns stay visible: {widths:?}"); + let trend = lines_of(&prims, tc); + assert_eq!(trend.len(), 1); + assert!(trend[0].len() >= 20, "the trendline is sampled over the plot, not the forecast: {}", trend[0].len()); +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..360c8747a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -351,7 +351,7 @@ fn add_element(s: &mut Session, p: &Json) -> Result { /// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart /// Element ▸ Trendline choices, plus the Format Trendline options). fn trendline_param(p: &Json) -> Result { - let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); let mut t = Trendline { forward: num("forward"), backward: num("backward"), diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b51f7bc98..73ce5e8d2 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -482,6 +482,18 @@ pub struct Trendline { pub dash: Option, } +impl Trendline { + /// Longest forward / backward forecast kept from a file or a command, in x units. Excel's own + /// limit is unconfirmed; the renderer additionally bounds the forecast by the plot's slot budget. + pub const MAX_FORECAST: f64 = 100_000.0; + + /// A forecast distance as stored: finite, not negative (NaN and infinity become 0) and at most + /// [`Self::MAX_FORECAST`]. + pub fn clamp_forecast(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.min(Self::MAX_FORECAST) } else { 0.0 } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Chart { pub id: u32, @@ -1046,3 +1058,19 @@ impl Default for PivotTable { } } } + +#[cfg(test)] +mod trendline_forecast_tests { + use super::Trendline; + + #[test] + fn clamp_forecast_bounds_hostile_values() { + assert_eq!(Trendline::clamp_forecast(2.5), 2.5); + assert_eq!(Trendline::clamp_forecast(0.0), 0.0); + assert_eq!(Trendline::clamp_forecast(-4.0), 0.0); + assert_eq!(Trendline::clamp_forecast(f64::NAN), 0.0); + assert_eq!(Trendline::clamp_forecast(f64::INFINITY), 0.0); + assert_eq!(Trendline::clamp_forecast(f64::NEG_INFINITY), 0.0); + assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST); + } +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..837b31b27 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -151,8 +151,8 @@ fn read_trendline(t: &El) -> Option { Some(Trendline { kind, name: t.child("name").map(|n| n.text.clone()), - forward: f64_val(t, "forward").unwrap_or(0.0).max(0.0), - backward: f64_val(t, "backward").unwrap_or(0.0).max(0.0), + forward: Trendline::clamp_forecast(f64_val(t, "forward").unwrap_or(0.0)), + backward: Trendline::clamp_forecast(f64_val(t, "backward").unwrap_or(0.0)), intercept: f64_val(t, "intercept"), display_equation: bool_val(t, "dispEq"), display_r_squared: bool_val(t, "dispRSqr"), diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs index d954c9bc6..92a1da18d 100644 --- a/crates/xlsx/src/tests/trendline_roundtrip.rs +++ b/crates/xlsx/src/tests/trendline_roundtrip.rs @@ -169,3 +169,20 @@ fn excel_style_trendlines_are_read() { assert_eq!(t[0].color, Some(Color::Theme(5, 0))); assert_eq!(t[1].kind, TrendlineKind::MovingAverage(2), "period clamps to 2..255"); } + +#[test] +fn a_huge_forecast_in_a_file_is_read_clamped() { + let t = |f: &str, b: &str| { + format!( + r#"Data!$B$2:$B$6"# + ) + }; + let read = |f: &str, b: &str| { + let root = crate::xml::parse(t(f, b).as_bytes()).unwrap(); + crate::chart::read_chart(&root).unwrap().series[0].trendlines[0].clone() + }; + let big = read("1000000", "1e300"); + assert_eq!((big.forward, big.backward), (Trendline::MAX_FORECAST, Trendline::MAX_FORECAST)); + let small = read("2.5", "-3"); + assert_eq!((small.forward, small.backward), (2.5, 0.0), "ordinary values are kept"); +} From 48696f9bcf860b15170b4e43d2bcd4d95bca6c46 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:44:20 +0200 Subject: [PATCH 059/446] XLSX: [Content_Types].xml is the first zip entry, workbook parts precede document properties (#1090) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/zip_order.rs | 33 ++++++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 10 +++++---- 4 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 crates/xlsx/src/tests/zip_order.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..549962d1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..409e27935 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -18,6 +18,7 @@ mod smooth_roundtrip; mod trendline_roundtrip; mod xls_tests; mod xlsb_tests; +mod zip_order; use std::io::Write; diff --git a/crates/xlsx/src/tests/zip_order.rs b/crates/xlsx/src/tests/zip_order.rs new file mode 100644 index 000000000..939b268d7 --- /dev/null +++ b/crates/xlsx/src/tests/zip_order.rs @@ -0,0 +1,33 @@ +//! libmagic (PHP `finfo`, `file`) detects an OOXML package from the first zip entries (#1090). + +use gridcraft_model::Workbook; + +use crate::write_xlsx; + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +#[test] +fn package_starts_with_content_types_rels_and_workbook_before_any_large_part() { + let mut wb = Workbook::new(); + // A long, hard-to-compress description makes docProps/core.xml big. + wb.props.description = + (0..20_000).map(|i| char::from(b'a' + (i * 7 % 26) as u8)).chain((0..20_000).map(|i| char::from(b'0' + (i * i % 10) as u8))).collect(); + let bytes = write_xlsx(&wb).unwrap(); + + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let names: Vec = (0..zip.len()).filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string())).collect(); + assert_eq!(names.first().map(String::as_str), Some("[Content_Types].xml"), "{names:?}"); + assert_eq!(names.get(1).map(String::as_str), Some("_rels/.rels"), "{names:?}"); + assert_eq!(names.get(2).map(String::as_str), Some("xl/_rels/workbook.xml.rels"), "{names:?}"); + assert_eq!(names.get(3).map(String::as_str), Some("xl/workbook.xml"), "{names:?}"); + + // No data descriptor (general-purpose bit 3) on the first local header, entry name at offset 30. + assert_eq!(bytes.get(..4), Some(&b"PK\x03\x04"[..])); + assert_eq!(bytes.get(6).map(|b| b & 0x08), Some(0)); + assert_eq!(bytes.get(30..49), Some(&b"[Content_Types].xml"[..])); + // libmagic looks for an `xl/` entry name within the first few KB: keep it well inside that. + let at = find(&bytes, b"xl/_rels/workbook.xml.rels"); + assert!(at.is_some_and(|p| p < 2048), "xl/ entry at {at:?}"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 06761f01d..c580f0a32 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -452,13 +452,15 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { ct.push_str(""); let mut zip = ZipOut::new(); zip.add("[Content_Types].xml", ct.as_bytes())?; - // Conventional order: rels, docProps, workbook, the rest. + // Excel's order: rels, workbook rels, workbook, the rest, document properties last. libmagic (PHP + // `finfo`, `file`) recognises an OOXML package only when an `xl/` entry name appears within the + // first few KB, so no user-sized part (document properties, a long description) may precede it. let rank = |n: &str| match n { "_rels/.rels" => 0, - n if n.starts_with("docProps/") => 1, + "xl/_rels/workbook.xml.rels" => 1, "xl/workbook.xml" => 2, - "xl/_rels/workbook.xml.rels" => 3, - _ => 4, + n if n.starts_with("docProps/") => 4, + _ => 3, }; let mut parts = std::mem::take(&mut out.parts); parts.sort_by_key(|(n, _)| rank(n)); From c74e1a4d994941fa30b35c4a9c67416c15bdd78d Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 09:29:01 +1100 Subject: [PATCH 060/446] fix(ui): menu items highlight under the pointer (#1174) The cell and header right-click menus, the ribbon dropdown menus and the data validation pick list drew their items with Button::frame(false), which turns the background off in every state, hover included. frame_when_inactive(false) keeps them flat at rest and shows the hover background, as Excel's menus do. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 6 +-- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/tests/menu_hover.rs | 71 ++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/menu_hover.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..9197cd75f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2879,7 +2879,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { ui.separator(); continue; } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { close = true; match id { "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), @@ -3088,7 +3088,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) ui.set_min_width(r.width().max(100.0)); egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { for it in &items { - if ui.add(egui::Button::new(it.as_str()).frame(false).min_size(vec2(r.width().max(100.0), 18.0))).clicked() { + if ui.add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), 18.0))).clicked() { app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); close = true; } @@ -3151,7 +3151,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ui.separator(); continue; } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { close = true; match id { "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..889c62b0a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -485,7 +485,7 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: continue; } let lang = app.ui.language; - let response = ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))); + let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0))); if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); } diff --git a/crates/ui-egui/tests/menu_hover.rs b/crates/ui-egui/tests/menu_hover.rs new file mode 100644 index 000000000..61e265235 --- /dev/null +++ b/crates/ui-egui/tests/menu_hover.rs @@ -0,0 +1,71 @@ +//! Menu items highlight under the pointer, as in Excel (#1174): no background at rest, the hover +//! background while the pointer is on an item. + +use egui::{Event, Modifiers, PointerButton, Pos2, Shape, accesskit::Role}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn cell_center(h: &Harness, cell: &str) -> egui::Rect { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sh, CellRef::parse(cell).unwrap()) +} + +fn right_click(h: &mut Harness, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } +} + +/// Is a filled rectangle in the hovered-widget colour painted under `at`? +fn hover_fill_under(h: &Harness, at: Pos2) -> bool { + let fill = h.ctx.global_style().visuals.widgets.hovered.weak_bg_fill; + h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == fill && r.rect.contains(at))) +} + +fn check_item(h: &mut Harness, label: &str) { + let at = h.get_by_role_and_label(Role::Button, label).rect().center(); + assert!(!hover_fill_under(h, at), "{label} has no background before the pointer is on it"); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + assert!(hover_fill_under(h, at), "{label} is highlighted under the pointer"); +} + +#[test] +fn cell_menu_items_highlight_under_the_pointer() { + let mut h = harness(); + let pos = cell_center(&h, "C5").center(); + right_click(&mut h, pos); + assert!(h.state().grid.context_menu.is_some()); + check_item(&mut h, "Copy"); +} + +#[test] +fn header_menu_items_highlight_under_the_pointer() { + let mut h = harness(); + let c1 = cell_center(&h, "C1"); + right_click(&mut h, egui::pos2(c1.center().x, c1.top() - 6.0)); + assert!(h.state().grid.header_menu.is_some()); + check_item(&mut h, "Copy"); +} From d95908f4ba24b6e03200ced474374947c43d056d Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 10:26:06 +1100 Subject: [PATCH 061/446] Ribbon tooltips show each platform's shortcuts (#1191) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 10 +++--- crates/ui-egui/src/i18n/cs.rs | 21 ++++------- crates/ui-egui/src/i18n/de.rs | 10 +++--- crates/ui-egui/src/i18n/es.rs | 10 +++--- crates/ui-egui/src/i18n/fr.rs | 10 +++--- crates/ui-egui/src/i18n/ko.rs | 10 +++--- crates/ui-egui/src/i18n/pt.rs | 19 +++------- crates/ui-egui/src/i18n/ru.rs | 10 +++--- crates/ui-egui/src/i18n/sr.rs | 10 +++--- crates/ui-egui/src/i18n/zh.rs | 10 +++--- crates/ui-egui/src/ribbon.rs | 30 ++++++++-------- crates/ui-egui/src/widgets.rs | 66 ++++++++++++++++++++++++++++++++--- 12 files changed, 118 insertions(+), 98 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..7eb5da86d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -187,12 +187,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Insert Copied Cells…", "コピーしたセルの挿入…"), ("Insert Cut Cells", "切り取ったセルの挿入"), ("Insert Paste", "挿入貼り付け"), - ("Cut (⌘X)", "切り取り (⌘X)"), - ("Copy (⌘C)", "コピー (⌘C)"), + ("Cut", "切り取り"), + ("Copy", "コピー"), ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), - ("Bold (⌘B)", "太字 (⌘B)"), - ("Italic (⌘I)", "斜体 (⌘I)"), - ("Underline (⌘U)", "下線 (⌘U)"), + ("Bold", "太字"), + ("Italic", "斜体"), ("Double Underline", "二重下線"), ("Strikethrough", "取り消し線"), ("Underline", "下線"), @@ -261,7 +260,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Clear Comments and Notes", "コメントとメモのクリア"), ("Clear Hyperlinks", "ハイパーリンクのクリア"), ("AutoSum", "オート SUM"), - ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), ("Flash\nFill", "フラッシュ フィル"), ("Flash Fill", "フラッシュ フィル"), ("Fill", "フィル"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..4c1a245b9 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -9,12 +9,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponovat", "Paste Link" => "Vložit propojení", "Paste Special…" => "Vložit jinak…", - "Cut (⌘X)" => "Vyjmout (⌘X)", - "Copy (⌘C)" => "Kopírovat (⌘C)", "Format Painter (double-click to keep it on)" => "Štětec formátu (dvojklikem zůstane zapnutý)", - "Bold (⌘B)" => "Tučné (⌘B)", - "Italic (⌘I)" => "Kurzíva (⌘I)", - "Underline (⌘U)" => "Podtržení (⌘U)", + "Bold" => "Tučné", + "Italic" => "Kurzíva", "Double Underline" => "Dvojité podtržení", "Strikethrough" => "Přeškrtnutí", "Underline" => "Podtržení", @@ -83,7 +80,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Vymazat komentáře a poznámky", "Clear Hyperlinks" => "Vymazat hypertextové odkazy", "AutoSum" => "Automatický\nsoučet", - "AutoSum (⌘⇧T)" => "Automatický součet (⌘⇧T)", "Flash\nFill" => "Dynamické\ndoplňování", "Flash Fill" => "Dynamické doplňování", "Fill" => "Vyplnit", @@ -451,9 +447,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit chart title" => "Upravit název grafu", "Erase ink strokes" => "Vymazat tahy rukopisu", "Every command run in this session (replayable)" => "Všechny příkazy spuštěné v této relaci (lze znovu přehrát)", - "Filter (⌘⇧F)" => "Filtr (⌘⇧F)", "Find & Select" => "Najít a vybrat", - "Flash Fill (⌘E)" => "Dynamické doplňování (⌘E)", "Forecast Sheet" => "List prognózy", "Format Chart pane" => "Podokno formátu grafu", "Format as Table" => "Formátovat jako tabulku", @@ -464,7 +458,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Import a text or CSV file" => "Importovat textový soubor nebo CSV", "Insert\nSlicer" => "Vložit\nprůřez", "Insert Function (⇧F3)" => "Vložit funkci (⇧F3)", - "Insert Link (⌘K)" => "Vložit odkaz (⌘K)", + "Insert Link" => "Vložit odkaz", "Insert Sheet" => "Vložit list", "Insert Slicer" => "Vložit průřez", "Insert a picture" => "Vložit obrázek", @@ -479,7 +473,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Name Manager" => "Správce názvů", "Page Breaks" => "Konce stránek", "Paper Size" => "Velikost papíru", - "Paste (⌘V)" => "Vložit (⌘V)", "Pen\nBlack" => "Pero\nčerné", "Pen\nBlue" => "Pero\nmodré", "Pen\nRed" => "Pero\nčervené", @@ -490,13 +483,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect Sheet" => "Zamknout list", "Protect Workbook structure" => "Zamknout strukturu sešitu", "Recommended Charts" => "Doporučené grafy", - "Redo (⌘Y)" => "Znovu (⌘Y)", + "Redo" => "Znovu", "Remove Duplicates" => "Odebrat duplicity", "Rename Sheet" => "Přejmenovat list", - "Save (⌘S)" => "Uložit (⌘S)", "Scatter or Bubble" => "Bodový nebo bublinový graf", "Search and run any command" => "Vyhledat a spustit libovolný příkaz", - "Search commands (⌘⇧Space)" => "Vyhledat příkazy (⌘⇧Space)", + "Search commands" => "Vyhledat příkazy", "Select Data" => "Vybrat data", "Select and move ink and shapes" => "Vybrat a přesunout rukopis a obrazce", "Series…" => "Řady…", @@ -507,12 +499,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Spelling (F7)" => "Pravopis (F7)", "Statistic" => "Statistické grafy", "Switch Row/Column" => "Prohodit řádky/sloupce", - "Table (⌘T)" => "Tabulka (⌘T)", "Table Styles" => "Styly tabulky", "Text Box" => "Textové pole", "Text to Columns" => "Text do sloupců", "Top Align" => "Zarovnat nahoru", - "Undo (⌘Z)" => "Zpět (⌘Z)", + "Undo" => "Zpět", "Undo list" => "Seznam akcí k vrácení", "Unhide Sheet…" => "Zobrazit list…", "Up" => "Nahoru", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b3..379d8cf1a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -10,12 +10,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponieren", "Paste Link" => "Verknüpfung einfügen", "Paste Special…" => "Inhalte einfügen…", - "Cut (⌘X)" => "Ausschneiden (⌘X)", - "Copy (⌘C)" => "Kopieren (⌘C)", + "Cut" => "Ausschneiden", + "Copy" => "Kopieren", "Format Painter (double-click to keep it on)" => "Format übertragen (Doppelklick zum Beibehalten)", - "Bold (⌘B)" => "Fett (⌘B)", - "Italic (⌘I)" => "Kursiv (⌘I)", - "Underline (⌘U)" => "Unterstrichen (⌘U)", + "Bold" => "Fett", + "Italic" => "Kursiv", "Double Underline" => "Doppelt unterstrichen", "Strikethrough" => "Durchgestrichen", "Underline" => "Unterstrichen", @@ -83,7 +82,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Kommentare und Notizen löschen", "Clear Hyperlinks" => "Hyperlinks löschen", "AutoSum" => "AutoSumme", - "AutoSum (⌘⇧T)" => "AutoSumme (⌘⇧T)", "Flash\nFill" => "Blitz-\nvorschau", "Flash Fill" => "Blitzvorschau", "Fill" => "Ausfüllen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..120ee8e73 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -11,12 +11,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponer", "Paste Link" => "Pegar vínculo", "Paste Special…" => "Pegado especial…", - "Cut (⌘X)" => "Cortar (⌘X)", - "Copy (⌘C)" => "Copiar (⌘C)", + "Cut" => "Cortar", + "Copy" => "Copiar", "Format Painter (double-click to keep it on)" => "Copiar formato (doble clic para mantenerlo activo)", - "Bold (⌘B)" => "Negrita (⌘B)", - "Italic (⌘I)" => "Cursiva (⌘I)", - "Underline (⌘U)" => "Subrayado (⌘U)", + "Bold" => "Negrita", + "Italic" => "Cursiva", "Double Underline" => "Subrayado doble", "Strikethrough" => "Tachado", "Underline" => "Subrayado", @@ -84,7 +83,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Borrar comentarios y notas", "Clear Hyperlinks" => "Borrar hipervínculos", "AutoSum" => "Autosuma", - "AutoSum (⌘⇧T)" => "Autosuma (⌘⇧T)", "Flash\nFill" => "Relleno\nrápido", "Flash Fill" => "Relleno rápido", "Fill" => "Rellenar", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 5d20ab0ca..fd8822707 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -15,12 +15,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Insérer les cellules copiées…", "Insert Cut Cells" => "Insérer les cellules coupées", "Insert Paste" => "Insérer en collant", - "Cut (⌘X)" => "Couper (⌘X)", - "Copy (⌘C)" => "Copier (⌘C)", + "Cut" => "Couper", + "Copy" => "Copier", "Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)", - "Bold (⌘B)" => "Gras (⌘B)", - "Italic (⌘I)" => "Italique (⌘I)", - "Underline (⌘U)" => "Souligné (⌘U)", + "Bold" => "Gras", + "Italic" => "Italique", "Double Underline" => "Double soulignement", "Strikethrough" => "Barré", "Underline" => "Souligné", @@ -89,7 +88,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Effacer les commentaires et les notes", "Clear Hyperlinks" => "Effacer les liens hypertexte", "AutoSum" => "Somme automatique", - "AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)", "Flash\nFill" => "Remplissage\ninstantané", "Flash Fill" => "Remplissage instantané", "Fill" => "Remplir", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..2e877e154 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "복사한 셀 삽입…", "Insert Cut Cells" => "잘라낸 셀 삽입", "Insert Paste" => "삽입 붙여넣기", - "Cut (⌘X)" => "잘라내기 (⌘X)", - "Copy (⌘C)" => "복사 (⌘C)", + "Cut" => "잘라내기", + "Copy" => "복사", "Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)", - "Bold (⌘B)" => "굵게 (⌘B)", - "Italic (⌘I)" => "기울임꼴 (⌘I)", - "Underline (⌘U)" => "밑줄 (⌘U)", + "Bold" => "굵게", + "Italic" => "기울임꼴", "Double Underline" => "이중 밑줄", "Strikethrough" => "취소선", "Underline" => "밑줄", @@ -86,7 +85,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "댓글 및 메모 지우기", "Clear Hyperlinks" => "하이퍼링크 지우기", "AutoSum" => "자동 합계", - "AutoSum (⌘⇧T)" => "자동 합계 (⌘⇧T)", "Flash\nFill" => "빠른\n채우기", "Flash Fill" => "빠른 채우기", "Fill" => "채우기", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..e63591d53 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -14,12 +14,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Inserir Células Copiadas…", "Insert Cut Cells" => "Inserir Células Recortadas", "Insert Paste" => "Inserir Colagem", - "Cut (⌘X)" => "Recortar (⌘X)", - "Copy (⌘C)" => "Copiar (⌘C)", "Format Painter (double-click to keep it on)" => "Pincel de Formatação (clique duas vezes para mantê-lo ativo)", - "Bold (⌘B)" => "Negrito (⌘B)", - "Italic (⌘I)" => "Itálico (⌘I)", - "Underline (⌘U)" => "Sublinhado (⌘U)", "Double Underline" => "Sublinhado Duplo", "Strikethrough" => "Riscado", "Underline" => "Sublinhado", @@ -87,7 +82,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Limpar Comentários e Notas", "Clear Hyperlinks" => "Limpar Hiperlinks", "AutoSum" => "Soma Automática", - "AutoSum (⌘⇧T)" => "Soma Automática (⌘⇧T)", "Flash\nFill" => "Preenchimento\nRelâmpago", "Flash Fill" => "Preenchimento Relâmpago", "Fill" => "Preencher", @@ -874,10 +868,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Erase ink strokes" => "Apagar traços de tinta", "Every command run in this session (replayable)" => "Todos os comandos executados nesta sessão (podem ser repetidos)", "Expand formula bar" => "Expandir barra de fórmulas", - "Filter (⌘⇧F)" => "Filtro (⌘⇧F)", "Filter by Selected Cell's Value" => "Filtrar pelo Valor da Célula Selecionada", "Find & Select" => "Localizar e Selecionar", - "Flash Fill (⌘E)" => "Preenchimento Relâmpago (⌘E)", "Forecast Sheet" => "Planilha de Previsão", "Forecast end (date or number; blank: a third of the history):" => "Fim da previsão (data ou número; em branco: um terço do histórico):", "Format Chart pane" => "Painel Formatar Gráfico", @@ -890,7 +882,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Import a text or CSV file" => "Importar um arquivo de texto ou CSV", "Insert Function" => "Inserir Função", "Insert Function (⇧F3)" => "Inserir Função (⇧F3)", - "Insert Link (⌘K)" => "Inserir Link (⌘K)", + "Insert Link" => "Inserir Link", "Insert Sheet" => "Inserir Planilha", "Insert Slicer" => "Inserir Segmentação de Dados", "Insert a picture" => "Inserir uma imagem", @@ -907,18 +899,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Próxima planilha", "Page Breaks" => "Quebras", "Paper Size" => "Tamanho", - "Paste (⌘V)" => "Colar (⌘V)", "Pick From Drop-down List…" => "Escolher na Lista Suspensa…", "Pie or Doughnut" => "Pizza ou Rosca", "Previous sheet" => "Planilha anterior", "Print Area" => "Área de Impressão", "Print Titles" => "Imprimir Títulos", "Protect Workbook structure" => "Proteger estrutura da pasta de trabalho", - "Redo (⌘Y)" => "Refazer (⌘Y)", - "Save (⌘S)" => "Salvar (⌘S)", + "Redo" => "Refazer", "Scatter or Bubble" => "Dispersão ou Bolhas", "Search and run any command" => "Pesquisar e executar qualquer comando", - "Search commands (⌘⇧Space)" => "Pesquisar comandos (⌘⇧Space)", + "Search commands" => "Pesquisar comandos", "Select and move ink and shapes" => "Selecionar e mover tinta e formas", "Series…" => "Série…", "Share: export or save a copy" => "Compartilhar: exportar ou salvar uma cópia", @@ -928,12 +918,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Spelling (F7)" => "Ortografia (F7)", "Statistic" => "Estatístico", "Switch Row/Column" => "Alternar Linha/Coluna", - "Table (⌘T)" => "Tabela (⌘T)", "Table Styles" => "Estilos de Tabela", "Text to Columns" => "Texto para Colunas", "The output range already holds data. Replace it with the report?" => "O intervalo de saída já contém dados. Substituí-los pelo relatório?", "Top Align" => "Alinhar em Cima", - "Undo (⌘Z)" => "Desfazer (⌘Z)", + "Undo" => "Desfazer", "Undo list" => "Lista de desfazer", "Unhide Sheet…" => "Reexibir Planilha…", "Up" => "Para Cima", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..09e38e916 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Вставить скопированные ячейки…", "Insert Cut Cells" => "Вставить вырезанные ячейки", "Insert Paste" => "Вставка со сдвигом", - "Cut (⌘X)" => "Вырезать (⌘X)", - "Copy (⌘C)" => "Копировать (⌘C)", + "Cut" => "Вырезать", + "Copy" => "Копировать", "Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)", - "Bold (⌘B)" => "Полужирный (⌘B)", - "Italic (⌘I)" => "Курсив (⌘I)", - "Underline (⌘U)" => "Подчёркивание (⌘U)", + "Bold" => "Полужирный", + "Italic" => "Курсив", "Double Underline" => "Двойное подчёркивание", "Strikethrough" => "Зачёркивание", "Underline" => "Подчёркивание", @@ -86,7 +85,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Удалить комментарии и заметки", "Clear Hyperlinks" => "Удалить гиперссылки", "AutoSum" => "Автосумма", - "AutoSum (⌘⇧T)" => "Автосумма (⌘⇧T)", "Flash\nFill" => "Мгновенное\nзаполнение", "Flash Fill" => "Мгновенное заполнение", "Fill" => "Заполнить", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index cbf18d4c4..7d70ab6f8 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Umetni kopirane ćelije…", "Insert Cut Cells" => "Umetni isečene ćelije", "Insert Paste" => "Umetni lepljenje", - "Cut (⌘X)" => "Iseci (⌘X)", - "Copy (⌘C)" => "Kopiraj (⌘C)", + "Cut" => "Iseci", + "Copy" => "Kopiraj", "Format Painter (double-click to keep it on)" => "Kopiraj oblikovanje (dupli klik da ostane uključeno)", - "Bold (⌘B)" => "Podebljano (⌘B)", - "Italic (⌘I)" => "Kurziv (⌘I)", - "Underline (⌘U)" => "Podvučeno (⌘U)", + "Bold" => "Podebljano", + "Italic" => "Kurziv", "Double Underline" => "Dvostruko podvlačenje", "Strikethrough" => "Precrtano", "Underline" => "Podvučeno", @@ -87,7 +86,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Obriši komentare i beleške", "Clear Hyperlinks" => "Obriši hiperveze", "AutoSum" => "Automatski zbir", - "AutoSum (⌘⇧T)" => "Automatski zbir (⌘⇧T)", "Flash\nFill" => "Brzo\npopunjavanje", "Flash Fill" => "Brzo popunjavanje", "Fill" => "Popuni", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..09ad57400 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "插入复制的单元格…", "Insert Cut Cells" => "插入剪切的单元格", "Insert Paste" => "插入粘贴", - "Cut (⌘X)" => "剪切 (⌘X)", - "Copy (⌘C)" => "复制 (⌘C)", + "Cut" => "剪切", + "Copy" => "复制", "Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)", - "Bold (⌘B)" => "粗体 (⌘B)", - "Italic (⌘I)" => "斜体 (⌘I)", - "Underline (⌘U)" => "下划线 (⌘U)", + "Bold" => "粗体", + "Italic" => "斜体", "Double Underline" => "双下划线", "Strikethrough" => "删除线", "Underline" => "下划线", @@ -86,7 +85,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "清除批注和备注", "Clear Hyperlinks" => "清除超链接", "AutoSum" => "自动求和", - "AutoSum (⌘⇧T)" => "自动求和 (⌘⇧T)", "Flash\nFill" => "快速\n填充", "Flash Fill" => "快速填充", "Fill" => "填充", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..265691f27 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -68,7 +68,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("start", json!({})); } - if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked() + if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked() // File operations must include the cell the user is still editing (as the File > Save menu item already does). && app.commit_edit(0, 0, false, false) { @@ -76,7 +76,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); - if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, ch)).clicked() && can_undo { + if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)).clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); @@ -92,7 +92,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, ch)).clicked() && can_redo { + if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)).clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); @@ -124,7 +124,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)); + let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); control_keytip(app, ui, &__k, "commandSearch"); if __k.clicked() { app.open_dialog("commandSearch", json!({})); @@ -505,7 +505,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let st = active_style(app); // Clipboard - let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true); + let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste")); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) @@ -557,12 +557,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - let __k = icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)); + let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0)); control_keytip(app, ui, &__k, "edit.cut"); if __k.clicked() { app.copy_to_clipboard(ui.ctx(), "edit.cut"); } - let __k = icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)); + let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0)); control_keytip(app, ui, &__k, "edit.copy"); if __k.clicked() { app.copy_to_clipboard(ui.ctx(), "edit.copy"); @@ -598,17 +598,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } }); ui.horizontal(|ui| { - let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold (⌘B)"); + let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold"); control_keytip(app, ui, &__k, "home.bold"); if __k.clicked() { act(app, "home.bold", json!({})); } - let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic (⌘I)"); + let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic"); control_keytip(app, ui, &__k, "home.italic"); if __k.clicked() { act(app, "home.italic", json!({})); } - let (u, ua) = split_button(ui, Icon::Underline, None, "Underline (⌘U)"); + let (u, ua) = split_button(ui, Icon::Underline, None, "Underline"); control_keytip(app, ui, &ua, "home.underline"); if u { act(app, "home.underline", json!({})); @@ -953,7 +953,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { // Editing ui.vertical(|ui| { ui.horizontal(|ui| { - let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum (⌘⇧T)"); + let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum"); control_keytip(app, ui, &sa, "formulas.autoSum"); if s { act(app, "formulas.autoSum", json!({})); @@ -1375,7 +1375,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "insert.pivotTable", json!({})); } - let __k = big_button(ui, Icon::Table, "Table", "Table (⌘T)", false); + let __k = big_button(ui, Icon::Table, "Table", "Table", false); control_keytip(app, ui, &__k, "insert.table"); if __k.clicked() { act(app, "insert.table", json!({})); @@ -1540,7 +1540,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { act(app, "insert.checkbox", json!({})); } sep(ui); - let __k = big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false); + let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false); control_keytip(app, ui, &__k, "dialog:insertLink"); if __k.clicked() { app.open_dialog("insertLink", json!({})); @@ -1909,7 +1909,7 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("sort", json!({})); } - let __k = big_button(ui, Icon::Filter, "Filter", "Filter (⌘⇧F)", false); + let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false); control_keytip(app, ui, &__k, "data.filter"); if __k.clicked() { act(app, "data.filter", json!({})); @@ -1932,7 +1932,7 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("textToColumns", json!({})); } - let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill (⌘E)", false); + let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false); control_keytip(app, ui, &__k, "edit.flashFill"); if __k.clicked() { act(app, "edit.flashFill", json!({})); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b49..400132bd4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -14,6 +14,41 @@ fn tr<'a>(ui: &Ui, text: &'a str) -> &'a str { i18n::current(ui.ctx()).tr(text) } +/// Keyboard shortcuts shown after a ribbon tooltip's command name: Excel for Mac's on macOS, +/// Excel for Windows' elsewhere (each one GridCraft binds). Only the name is translated. +const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[ + ("Save", "⌘S", "Ctrl+S"), + ("Undo", "⌘Z", "Ctrl+Z"), + ("Redo", "⌘Y", "Ctrl+Y"), + ("Paste", "⌘V", "Ctrl+V"), + ("Cut", "⌘X", "Ctrl+X"), + ("Copy", "⌘C", "Ctrl+C"), + ("Bold", "⌘B", "Ctrl+B"), + ("Italic", "⌘I", "Ctrl+I"), + ("Underline", "⌘U", "Ctrl+U"), + ("AutoSum", "⌘⇧T", "Alt+="), + ("Table", "⌘T", "Ctrl+T"), + ("Insert Link", "⌘K", "Ctrl+K"), + ("Filter", "⌘⇧F", "Ctrl+Shift+L"), + ("Flash Fill", "⌘E", "Ctrl+E"), +]; + +/// The shortcut a ribbon tooltip shows after `tip`, for this platform (`mac`). +pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { + TOOLTIP_SHORTCUTS.iter().find(|(name, _, _)| *name == tip).map(|(_, m, other)| if mac { *m } else { *other }) +} + +/// A ribbon tooltip: the translated command name, then its shortcut for this platform, as Excel +/// shows them ("Bold (Ctrl+B)" on Windows, "Bold (⌘B)" on macOS). +fn tip_text(ui: &Ui, tip: &str) -> String { + // A name shared with a two-line ribbon label (Czech "Automatický\nsoučet") reads as one line. + let name = tr(ui, tip).replace('\n', " "); + match tooltip_shortcut(tip, cfg!(target_os = "macos")) { + Some(keys) => format!("{name} ({keys})"), + None => name.to_string(), + } +} + /// A flat square icon button with hover highlight and tooltip. pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egui::Vec2) -> Response { let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); @@ -24,7 +59,7 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// A toggle-able small button (e.g. Bold) showing a checked state. @@ -38,7 +73,7 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, t.text); - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// Large ribbon button: 32px icon over a one- or two-line label. @@ -73,7 +108,7 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ); } } - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// Small ribbon button: 16px icon with optional label to the right. @@ -101,7 +136,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// A split button: main part runs the default action, the arrow opens a menu. @@ -122,7 +157,7 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) ui.painter().rect_filled(Rect::from_min_size(pos2(main.left() + 4.0, main.bottom() - 5.0), vec2(17.0, 3.0)), 0.0, c); } icons::paint(ui.painter(), Rect::from_center_size(arrow.center(), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim); - (m.on_hover_text(tr(ui, tip)).clicked(), a) + (m.on_hover_text(tip_text(ui, tip)).clicked(), a) } /// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is @@ -362,3 +397,24 @@ pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { app.notice = None; } } + +#[cfg(test)] +mod tooltip_shortcut_tests { + use super::tooltip_shortcut; + + #[test] + fn tooltips_show_each_platforms_own_shortcut() { + // #1191: Windows and Linux show Excel for Windows' shortcuts, never the Mac ⌘ ones. + assert_eq!(tooltip_shortcut("Bold", false), Some("Ctrl+B")); + assert_eq!(tooltip_shortcut("AutoSum", false), Some("Alt+=")); + assert_eq!(tooltip_shortcut("Filter", false), Some("Ctrl+Shift+L")); + assert_eq!(tooltip_shortcut("Bold", true), Some("⌘B")); + assert_eq!(tooltip_shortcut("Filter", true), Some("⌘⇧F")); + // Search commands has no shortcut of its own (Ctrl+Shift+Space selects all). + assert_eq!(tooltip_shortcut("Search commands", false), None); + for (name, mac, other) in super::TOOLTIP_SHORTCUTS { + assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}"); + assert!(mac.starts_with('⌘'), "{name}: {mac}"); + } + } +} From 5e5e131be7e4af8c9a3a4119e882bd341a3a7041 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 03:03:28 +0200 Subject: [PATCH 062/446] Grid: an armed Format Painter pastes onto the next cells clicked or dragged (fixes #1211) --- crates/ui-egui/src/grid.rs | 16 +++++- crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/tests/format_painter.rs | 78 ++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/format_painter.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..9a953c36e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2258,10 +2258,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Release. if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { match std::mem::take(&mut app.grid.drag) { - Drag::Select if resp.drag_stopped() => { - if let Some(p) = pos { + Drag::Select => { + if resp.drag_stopped() + && let Some(p) = pos + { select_to_pointer(app, geo, sh, wb, p); } + // An armed Format Painter pastes onto the cells just clicked or dragged over + // (not while pointing a formula reference, which also drags a Select). + if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } } Drag::Fill { target: Some(t) } => { let src = sel.current(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..5fd93c117 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -569,6 +569,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); + fp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), fp_on, "Format Painter")); control_keytip(app, ui, &fp, "edit.formatPainter"); if fp.double_clicked() { act(app, "edit.formatPainter", json!({"sticky": true})); diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/format_painter.rs new file mode 100644 index 000000000..1a9c5742c --- /dev/null +++ b/crates/ui-egui/tests/format_painter.rs @@ -0,0 +1,78 @@ +//! Format Painter through the real ribbon button and a click on the grid. + +use egui::accesskit::Toggled; +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::Style; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["source"]]})).unwrap(); + session.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); + session.execute("home.fillColor", json!({"range": "A1", "color": "#FFFF00"})).unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn style(h: &Harness, a1: &str) -> Style { + let doc = h.state().session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + doc.wb.styles.get(sheet.style_id(CellRef::parse(a1).unwrap())).clone() +} + +fn point(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center() +} + +fn pressed(h: &Harness) -> bool { + h.get_by_label("Format Painter").accesskit_node().toggled() == Some(Toggled::True) +} + +fn click_cell(h: &mut Harness, a1: &str) { + let at = point(h, a1); + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +#[test] +fn ribbon_button_picks_up_the_format_and_a_click_on_a_cell_pastes_it() { + let mut h = harness(); + let source = style(&h, "A1"); + assert!(source.font.bold); + assert_ne!(style(&h, "C3"), source); + + h.get_by_label("Format Painter").click(); + h.run_steps(2); + assert!(h.state().session.format_painter.is_some(), "the button must arm the painter"); + assert!(pressed(&h), "the armed button must show as pressed"); + + click_cell(&mut h, "C3"); + assert_eq!(style(&h, "C3"), source, "clicking C3 must paste A1's format"); + assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use"); + assert!(!pressed(&h)); +} From 3f520a3420ec075ccc45047f31cfadf7c9065188 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 03:03:33 +0200 Subject: [PATCH 063/446] File: Save a Copy asks where to save a bare name instead of writing it to the working directory (fixes #1212) --- crates/ui-egui/src/dialogs.rs | 12 ++++++++- crates/ui-egui/tests/file_menu.rs | 43 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..9b42a70ff 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1170,7 +1170,17 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { d.values.insert("path".into(), json!(path.clone())); ok_cancel(ui, &mut confirm, &mut open); if confirm { - let target = if path.is_empty() { app.services.pick_save.as_ref().and_then(|f| f(&format!("Copy.{fmt}"))) } else { Some(path) }; + // A bare name has no folder: written as typed it would land in the + // process's working directory (`/` from the Finder, the install folder on + // Windows), never where the user looks. The picker chooses the folder. + let pick = app.services.pick_save.as_ref(); + let target = if path.is_empty() { + pick.and_then(|f| f(&format!("Copy.{fmt}"))) + } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { + f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) + } else { + Some(path) + }; if let Some(t) = target { let id = match fmt.as_str() { "csv" => "file.exportCsv", diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 691eef367..c1b531e96 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -217,3 +217,46 @@ fn a_cancelled_export_pdf_picker_writes_nothing() { assert!(h.state().message.is_none()); assert!(h.state_mut().session.take_ui_requests().is_empty()); } + +/// Share › Export as Web Page (and the native File menu's Save as Web Page) asks for a name in its +/// own dialog. A bare name has no folder: written as typed it lands in the process's working +/// directory, where nobody looks for it (#1212). The save picker chooses the folder instead. +#[test] +fn save_a_copy_with_a_bare_name_asks_where_to_save_it() { + let dir = std::env::temp_dir().join(format!("gridcraft-save-copy-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let asked = Arc::new(Mutex::new(Vec::::new())); + let (capture, target) = (asked.clone(), dir.clone()); + let mut h = harness(Services { + pick_save: Some(Box::new(move |suggested| { + capture.lock().unwrap().push(suggested.to_string()); + Some(target.join("Chosen.html").to_string_lossy().into_owned()) + })), + ..Default::default() + }); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + let save_copy = |h: &mut egui_kittest::Harness<'static, SheetApp>, path: &str| { + h.state_mut().open_dialog("saveCopy", serde_json::json!({"format": "html"})); + h.run_steps(3); + h.state_mut().dialog.as_mut().unwrap().values.insert("path".into(), serde_json::json!(path)); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + }; + let name = format!("gridcraft-save-copy-{}", std::process::id()); + save_copy(&mut h, &name); + let stray = std::path::PathBuf::from(format!("{name}.html")); + let written_in_cwd = stray.exists(); + let _ = std::fs::remove_file(&stray); + assert!(!written_in_cwd, "a bare name is not written to the working directory"); + assert_eq!(*asked.lock().unwrap(), vec![format!("{name}.html")], "the picker is offered the typed name"); + assert!(std::fs::read_to_string(dir.join("Chosen.html")).unwrap().contains("42"), "the page is written where the picker says"); + assert!(h.state().message.is_none()); + // A full path skips the picker; a write that fails is reported, not swallowed. + save_copy(&mut h, &dir.join("Named.html").to_string_lossy()); + assert!(dir.join("Named.html").exists()); + save_copy(&mut h, &dir.join("missing").join("Lost.html").to_string_lossy()); + assert!(h.state().message.is_some(), "a failed write is reported"); + assert_eq!(asked.lock().unwrap().len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} From b5aacceb11fb760c41ad588160caadbfdc483a64 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 13:36:51 +1100 Subject: [PATCH 064/446] Sheet tabs: right-clicking a tab activates its sheet As in Excel, a right-click on an inactive tab now activates that sheet, so the tab menu's Protect/Unprotect Sheet and Select All Sheets act on the sheet that was clicked rather than the one that was active. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr --- crates/ui-egui/src/tabs.rs | 4 +++- crates/ui-egui/tests/sheet_tabs.rs | 32 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..ddb083948 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -148,7 +148,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } else { ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } - if resp.clicked() { + // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect + // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. + if resp.clicked() || (resp.secondary_clicked() && !active) { activate_sheet(app, i); } if resp.double_clicked() { diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index 7b43cd12b..34d24c854 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -162,3 +162,35 @@ fn overflow_leaves_the_worksheet_scrollbar_reachable() { assert!(track.left() >= 0.0 && track.right() <= width + 0.5, "{width}: the scrollbar is offscreen: {track:?}"); } } + +#[test] +fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() { + let mut h = harness(1200.0, 0); + h.state_mut().run("sheet.activate", json!({"sheet":3})).unwrap(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + // Tall enough for the whole tab menu to be painted. + h.set_size(vec2(1200.0, 600.0)); + h.run_steps(20); + let (text, _) = tab(&h, "Tab03").unwrap(); + let at = text.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 3, "right-clicking Tab03 should activate it"); + // The menu now offers Unprotect for the protected sheet that was clicked, not Protect for the one that was active. + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t == "Unprotect Sheet…"), "menu should offer Unprotect Sheet…, painted: {texts:?}"); +} From f519fd67f68fef644bcf691fa932e3cb942f6d09 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 04:49:33 +0200 Subject: [PATCH 065/446] XLSX: keep a dxf's protection, font size and face, and the font parts it turns off (part of #1209) --- crates/model/src/style.rs | 21 +++++++++ crates/xlsx/src/styles.rs | 76 +++++++++++++++++++++++-------- crates/xlsx/src/tests/fixtures.rs | 35 ++++++++++++++ 3 files changed, 112 insertions(+), 20 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index d3976876e..800bb34d1 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -339,6 +339,27 @@ pub struct Style { /// Number format code; "General" by default. pub num_fmt: NumFmt, pub protection: Protection, + /// Differential formats only. + #[serde(default)] + pub dxf_stated: DxfStated, +} + +/// The parts a differential format states at a value its `Style` can't tell from unset: a dxf's +/// default parts are unset, so a bold turned off, a face, a size or a protection that matches the +/// defaults would otherwise read as nothing. The values are the style's own; this records that +/// the format states them, and only where they match the defaults, so a format built in the app +/// and the same format read back from a file stay equal. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DxfStated { + pub bold: bool, + pub italic: bool, + pub strike: bool, + pub underline: bool, + pub size: bool, + pub name: bool, + /// ``: the theme font the face follows (`major`, `minor` or `none`). + pub scheme: Option, + pub protection: bool, } #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 01f1fa632..49ef72ca5 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::fmt::Write as _; -use gridcraft_model::style::Protection; +use gridcraft_model::style::{DxfStated, Protection}; use gridcraft_model::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, Underline, VAlign, VertAlign, Workbook, }; @@ -348,10 +348,23 @@ pub fn read_styles(root: &El, checkboxes: &[bool]) -> StylesIn { d.kids("dxf") .map(|x| { let mut s = Style::default(); - if let Some(f) = x.child("font") { - let f = read_font(f, &palette); - // Differential fonts rarely name a face; keep the default face and size. - s.font = Font { name: s.font.name.clone(), size: s.font.size, ..f }; + if let Some(e) = x.child("font") { + let f = read_font(e, &palette); + // Differential fonts rarely name a face; unless stated, keep the default face and size. + let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some(); + let has_name = e.child("name").and_then(|n| n.attr("val")).is_some(); + let name = if has_name { f.name.clone() } else { s.font.name.clone() }; + let size = if has_size { f.size } else { s.font.size }; + let def = Font::default(); + let st = &mut s.dxf_stated; + st.bold = e.child("b").is_some() && !f.bold; + st.italic = e.child("i").is_some() && !f.italic; + st.strike = e.child("strike").is_some() && !f.strike; + st.underline = e.child("u").is_some() && f.underline == Underline::None; + st.size = has_size && size == def.size; + st.name = has_name && name == def.name; + st.scheme = e.child_val("scheme").map(str::to_string); + s.font = Font { name, size, ..f }; } if let Some(n) = x.child("numFmt") { if let Some(code) = n.attr("formatCode") { @@ -371,6 +384,7 @@ pub fn read_styles(root: &El, checkboxes: &[bool]) -> StylesIn { } if let Some(p) = x.child("protection") { s.protection = read_protection(p); + s.dxf_stated.protection = s.protection == Protection::default(); } s }) @@ -396,19 +410,20 @@ pub fn color_attrs(c: &Color) -> String { } } -/// `minor`: the font follows the theme's body font (``). -fn font_xml(f: &Font, dxf: bool, minor: bool) -> String { +/// `minor`: the font follows the theme's body font (``). `dxf`: a +/// differential format's font, which writes only what it states. +fn font_xml(f: &Font, dxf: Option<&DxfStated>, minor: bool) -> String { + let stated = dxf.cloned().unwrap_or_default(); let mut s = String::from(""); - if f.bold { - s.push_str(""); - } - if f.italic { - s.push_str(""); - } - if f.strike { - s.push_str(""); + for (on, said, tag) in [(f.bold, stated.bold, "b"), (f.italic, stated.italic, "i"), (f.strike, stated.strike, "strike")] { + if on { + let _ = write!(s, "<{tag}/>"); + } else if said { + let _ = write!(s, "<{tag} val=\"0\"/>"); + } } match f.underline { + Underline::None if stated.underline => s.push_str(""), Underline::None => {} Underline::Single => s.push_str(""), Underline::Double => s.push_str(""), @@ -420,19 +435,27 @@ fn font_xml(f: &Font, dxf: bool, minor: bool) -> String { VertAlign::Superscript => s.push_str(""), VertAlign::Subscript => s.push_str(""), } - if !dxf { + let def = Font::default(); + if dxf.is_none() || stated.size || f.size != def.size { let _ = write!(s, "", num(f.size as f64)); } if f.color != Color::Auto { let _ = write!(s, "", color_attrs(&f.color)); - } else if !dxf { + } else if dxf.is_none() { s.push_str(""); } - if !dxf { + if dxf.is_none() { let _ = write!(s, "", esc_attr(&f.name)); if minor { s.push_str(""); } + } else { + if stated.name || f.name != def.name { + let _ = write!(s, "", esc_attr(&f.name)); + } + if let Some(sc) = &stated.scheme { + let _ = write!(s, "", esc_attr(sc)); + } } s.push_str(""); s @@ -664,14 +687,24 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St for d in dxfs { dx.push_str(""); let def = Style::default(); + let st = &d.dxf_stated; if d.font.bold || d.font.italic || d.font.strike || d.font.underline != Underline::None || d.font.color != Color::Auto || d.font.vert != VertAlign::Baseline + || d.font.size != def.font.size + || d.font.name != def.font.name + || st.bold + || st.italic + || st.strike + || st.underline + || st.size + || st.name + || st.scheme.is_some() { - dx.push_str(&font_xml(&d.font, true, false)); + dx.push_str(&font_xml(&d.font, Some(st), false)); } if d.num_fmt != def.num_fmt { let id = nf.id(d.num_fmt.as_str()); @@ -683,6 +716,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St if let Some(a) = alignment_xml(&d.align) { dx.push_str(&a); } + if st.protection || d.protection != def.protection { + let _ = write!(dx, "