From 43a118c543cdfb7668848d6bb2031b509f277b57 Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 02:01:49 +0200 Subject: [PATCH 01/30] 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 02/30] 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 03/30] 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 cf587ffe3b7aebcc197f12bd8c8ebabd841d22f6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:16:49 +0200 Subject: [PATCH 04/30] 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 05/30] 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 06c8ff640da1676ba504ec78db1e5b6f2bc1fc40 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:31:28 +0200 Subject: [PATCH 06/30] 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 07/30] 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 3272097e15252a29fe66ae313d5aa39045ac8336 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 12:02:55 +0200 Subject: [PATCH 08/30] 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 09/30] 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 c05285e181d075f2432b8c98487669f7f4c79332 Mon Sep 17 00:00:00 2001 From: pourie-he Date: Tue, 13 Oct 2026 23:12:38 +1100 Subject: [PATCH 10/30] 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 11/30] 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 12/30] 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 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 13/30] =?UTF-8?q?Colour=20menus:=20More=20Colors=E2=80=A6?= =?UTF-8?q?=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 14/30] 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 15/30] 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 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 16/30] 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 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 17/30] 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 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 18/30] 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 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 19/30] 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 20/30] 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 568dd5826479d1adc33239dab1e0cd7c3245f8dd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:13:05 +0200 Subject: [PATCH 21/30] 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 c74e1a4d994941fa30b35c4a9c67416c15bdd78d Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 09:29:01 +1100 Subject: [PATCH 22/30] 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 23/30] 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 c0d6f290e77b4b120d3542c5e380b075a3f7bb5e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 21:20:52 +0200 Subject: [PATCH 24/30] Name Manager: preview a name over several ranges through parse_name --- crates/engine/src/cmd/formulas.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 09a0a4644..65a92979c 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,6 +1,6 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. -use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_core::{CellError, CellRef, RangeRef, Value}; use gridcraft_model::{CalcMode, DefinedName, Formula}; use serde_json::{Value as Json, json}; @@ -389,7 +389,9 @@ fn name_manager(s: &mut Session, _: &Json) -> Result { .filter(|n| !n.hidden) .map(|n| { let sheet = n.scope.unwrap_or(d.wb.active_sheet); - let v = gridcraft_calc::evaluate(&d.wb, sheet, CellRef::default(), &n.formula); + // parse_name, not evaluate's parse: a name's top-level commas are unions. + let v = gridcraft_formula::parse_name(&n.formula) + .map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e)); json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment}) }) .collect(); @@ -633,4 +635,16 @@ mod tests { 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)); } + + #[test] + fn name_manager_previews_a_name_over_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(); + s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap(); + let list = s.execute("formulas.nameManager", json!({})).unwrap(); + let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap(); + // The union parses as one, so the preview is what the union gives in a cell, not #NAME?. + assert_eq!(u["value"], json!({"error": "#VALUE!"})); + } } From 7ba381d9ae32e20ea4882e666666729fc318de0b Mon Sep 17 00:00:00 2001 From: Ufuk Efe Date: Mon, 12 Oct 2026 12:26:03 +0300 Subject: [PATCH 25/30] i18n: Turkish (tr) interface language --- contributors/people.toml | 7 +- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/i18n.rs | 18 +- crates/ui-egui/src/i18n/tr.rs | 414 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/theme.rs | 2 +- crates/ui-egui/tests/formula_locale.rs | 15 + crates/ui-egui/tests/interaction.rs | 2 + docs/localization-parity.md | 4 +- 10 files changed, 466 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/i18n/tr.rs diff --git a/contributors/people.toml b/contributors/people.toml index be84b5b01..0bc376396 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -60,11 +60,14 @@ sync_github_name = true [people.jsymes21-byte] real_name = "James Symes" sync_github_name = true - - [people.Silvertree2010] display_name = "Netter" sync_github_name = false [people.Shnrqpdr] sync_github_name = true + +[people.yeniufuklar-com] +real_name = "Ufuk Efe" +display_name = "Ufuk Efe" +sync_github_name = true diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index a171199a1..dcee71173 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,7 +1,7 @@ //! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG. //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] -//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt|cs] +//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr] //! [--dialog 'name={json}'] [--hf-edit header|footer] out.png` use gridcraft_engine::Session; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea5cfae0..96f474739 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -195,7 +195,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "Submit Print Job", "existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog", ), - ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"), + ( + "app.language.set", + "Interface Language", + "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"|\"fr\"|\"tr\"} (alias: code)", + ), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 98919f8a4..43830e106 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -17,6 +17,7 @@ mod ko; mod pt; mod ru; mod sr; +mod tr; mod zh; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -35,10 +36,12 @@ pub enum Language { Cs, Fr, Sr, + Tr, } 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::Tr]; /// 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::Tr => "Türkçe", } } @@ -71,6 +75,7 @@ impl Language { Self::Cs => "cs", Self::Fr => "fr", Self::Sr => "sr", + Self::Tr => "tr", } } @@ -91,6 +96,7 @@ impl Language { "cs" => Some(Self::Cs), "fr" => Some(Self::Fr), "sr" => Some(Self::Sr), + "tr" => Some(Self::Tr), _ => None, } } @@ -129,6 +135,7 @@ impl Language { Self::Cs => cs::translate(text), Self::Fr => fr::translate(text), Self::Sr => sr::translate(text), + Self::Tr => tr::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!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); @@ -624,6 +632,8 @@ mod tests { assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Fr.tr("Data"), "Données"); assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Tr.tr("Data"), "Veri"); + assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::PtBr.tr("Date"), "Data"); assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); @@ -634,6 +644,7 @@ mod tests { assert_eq!(Language::Cs.tr("Data"), "Data"); assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Tr.tr("Formulas"), "Formüller"); // A `context|label` key shows only its label in English and splits the translation. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); @@ -644,6 +655,8 @@ 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::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü"); + assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme"); assert_eq!(Language::parse("xx"), None); } @@ -738,6 +751,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("tr-TR"), Some(Language::Tr)); + assert_eq!(Language::from_tag("tr_TR.UTF-8"), Some(Language::Tr)); + assert_eq!(Language::parse("tr"), Some(Language::Tr)); } #[test] diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs new file mode 100644 index 000000000..becbc3f5f --- /dev/null +++ b/crates/ui-egui/src/i18n/tr.rs @@ -0,0 +1,414 @@ +//! Turkish translations for the interface strings in i18n.rs. Labels follow the wording Turkish +//! spreadsheet users know; anything absent falls back to English. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Yapıştır", + "Paste Values" => "Değerleri Yapıştır", + "Paste Formulas" => "Formülleri Yapıştır", + "Paste Formatting" => "Biçimlendirmeyi Yapıştır", + "Transpose" => "Devrik Dönüştür", + "Paste Link" => "Bağlantı Yapıştır", + "Paste Special…" => "Özel Yapıştır…", + "Cut (⌘X)" => "Kes (⌘X)", + "Copy (⌘C)" => "Kopyala (⌘C)", + "Format Painter (double-click to keep it on)" => "Biçim Boyacısı (açık tutmak için çift tıklayın)", + "Bold (⌘B)" => "Kalın (⌘B)", + "Italic (⌘I)" => "İtalik (⌘I)", + "Underline (⌘U)" => "Altı Çizili (⌘U)", + "Double Underline" => "Çift Alt Çizgi", + "Strikethrough" => "Üstü Çizili", + "Underline" => "Altı Çizili", + "Increase Font Size" => "Yazı Tipi Boyutunu Büyüt", + "Decrease Font Size" => "Yazı Tipi Boyutunu Küçült", + "Wrap Text" => "Metni Kaydır", + "Merge & Center" => "Birleştir ve Ortala", + "Merge Cells" => "Hücreleri Birleştir", + "Merge Across" => "Yatay Birleştir", + "Unmerge Cells" => "Hücreleri Çöz", + "Fill Color" => "Dolgu Rengi", + "Font Color" => "Yazı Tipi Rengi", + "Borders" => "Kenarlıklar", + "Top\nAlign" => "Üste\nHizala", + "Middle\nAlign" => "Ortaya\nHizala", + "Bottom\nAlign" => "Alta\nHizala", + "Left" => "Sol", + "Right" => "Sağ", + "Center" => "Orta", + "Horizontal" => "Yatay", + "Vertical Text" => "Dikey Metin", + "Decrease Indent" => "Girintiyi Azalt", + "Increase Indent" => "Girintiyi Artır", + "Orientation" => "Yönlendirme", + "Angle Counterclockwise" => "Saat Yönünün Tersine Açılı", + "Angle Clockwise" => "Saat Yönünde Açılı", + "Rotate Text Up" => "Metni Yukarı Döndür", + "Rotate Text Down" => "Metni Aşağı Döndür", + "Accounting Number Format" => "Muhasebe Sayı Biçimi", + "Percent Style" => "Yüzde Stili", + "Comma Style" => "Virgül Stili", + "Increase Decimal" => "Ondalık Artır", + "Decrease Decimal" => "Ondalık Azalt", + "More Accounting Formats…" => "Diğer Muhasebe Biçimleri…", + "Conditional\nFormatting" => "Koşullu\nBiçimlendirme", + "Conditional Formatting" => "Koşullu Biçimlendirme", + "Format\nas Table" => "Tablo Olarak\nBiçimlendir", + "Cell\nStyles" => "Hücre\nStilleri", + "Format Cells…" => "Hücreleri Biçimlendir…", + "Format Cell Alignment…" => "Hücre Hizalamayı Biçimlendir…", + "Format\nPane" => "Biçim\nBölmesi", + "Format" => "Biçim", + "Insert" => "Ekle", + "Insert Cells…" => "Hücre Ekle…", + "Delete" => "Sil", + "Delete Cells…" => "Hücreleri Sil…", + "Insert Sheet Rows" => "Sayfa Satırları Ekle", + "Insert Sheet Columns" => "Sayfa Sütunları Ekle", + "Delete Sheet Rows" => "Sayfa Satırlarını Sil", + "Delete Sheet Columns" => "Sayfa Sütunlarını Sil", + "Row Height…" => "Satır Yüksekliği…", + "AutoFit Row Height" => "Satır Yüksekliğini En Uygun Yap", + "Column Width…" => "Sütun Genişliği…", + "AutoFit Column Width" => "Sütun Genişliğini En Uygun Yap", + "Default Width…" => "Varsayılan Genişlik…", + "Hide Rows" => "Satırları Gizle", + "Unhide Rows" => "Satırları Göster", + "Hide Columns" => "Sütunları Gizle", + "Unhide Columns" => "Sütunları Göster", + "Lock Cell" => "Hücreyi Kilitle", + "Clear" => "Temizle", + "Clear All" => "Tümünü Temizle", + "Clear Formats" => "Biçimleri Temizle", + "Clear Contents" => "İçeriği Temizle", + "Clear Comments and Notes" => "Açıklamaları ve Notları Temizle", + "Clear Hyperlinks" => "Köprüleri Temizle", + "AutoSum" => "Otomatik Toplam", + "AutoSum (⌘⇧T)" => "Otomatik Toplam (⌘⇧T)", + "Flash\nFill" => "Hızlı\nDoldurma", + "Flash Fill" => "Hızlı Doldurma", + "Fill" => "Doldur", + "Clear Print Area" => "Yazdırma Alanını Temizle", + "Find &\nSelect" => "Bul ve\nSeç", + "Find…" => "Bul…", + "Replace…" => "Değiştir…", + "Go To…" => "Git…", + "Go To Special…" => "Özel Git…", + "PivotTable" => "Özet Tablo", + "Recommended\nCharts" => "Önerilen\nGrafikler", + "Table" => "Tablo", + "Pictures" => "Resimler", + "Shapes" => "Şekiller", + "Icons" => "Simgeler", + "Text\nBox" => "Metin\nKutusu", + "Comment" => "Açıklama", + "New\nComment" => "Yeni\nAçıklama", + "New Note" => "Yeni Not", + "Show/Hide Note" => "Notu Göster/Gizle", + "Header &\nFooter" => "Üstbilgi ve\nAltbilgi", + "Text to\nColumns" => "Metni Sütunlara\nDönüştür", + "Link" => "Köprü", + "Symbol" => "Simge", + "Sparklines" => "Mini Grafikler", + "Header & Footer" => "Üstbilgi ve Altbilgi", + "Draw with ink" => "Mürekkeple Çiz", + "Eraser" => "Silgi", + "Ink to\nShape" => "Mürekkebi\nŞekle Dönüştür", + "Select\nObjects" => "Nesneleri\nSeç", + "Themes" => "Temalar", + "Margins" => "Kenar Boşlukları", + "Left Margin" => "Sol Kenar Boşluğu", + "Right Margin" => "Sağ Kenar Boşluğu", + "Top Margin" => "Üst Kenar Boşluğu", + "Bottom Margin" => "Alt Kenar Boşluğu", + "Header Margin" => "Üstbilgi Kenar Boşluğu", + "Footer Margin" => "Altbilgi Kenar Boşluğu", + "Size" => "Boyut", + "Print\nArea" => "Yazdırma\nAlanı", + "Set Print Area" => "Yazdırma Alanını Belirle", + "Breaks" => "Kesmeler", + "Insert Page Break" => "Sayfa Sonu Ekle", + "Remove Page Break" => "Sayfa Sonunu Kaldır", + "Reset All Page Breaks" => "Tüm Sayfa Sonlarını Sıfırla", + "Print\nTitles" => "Başlıkları\nYazdır", + "Narrow" => "Dar", + "Wide" => "Geniş", + "Normal" => "Normal", + "Portrait" => "Dikey", + "Landscape" => "Yatay", + "Custom Margins…" => "Özel Kenar Boşlukları…", + "Insert\nFunction" => "İşlev\nEkle", + "Name\nManager" => "Ad\nYöneticisi", + "Define Name" => "Ad Tanımla", + "Use in Formula" => "Formülde Kullan", + "Create from Selection" => "Seçimden Oluştur", + "Trace Precedents" => "Etkileyenleri İzle", + "Trace Dependents" => "Etkilenenleri İzle", + "Remove Arrows" => "Okları Kaldır", + "Show Formulas" => "Formülleri Göster", + "Error Checking" => "Hata Denetimi", + "Evaluate Formula" => "Formülü Değerlendir", + "Watch Window" => "Gözcü Penceresi", + "Calculation\nOptions" => "Hesaplama\nSeçenekleri", + "Calculate Now" => "Şimdi Hesapla", + "Calculate Sheet" => "Sayfayı Hesapla", + "Automatic" => "Otomatik", + "Automatic Except for Data Tables" => "Veri Tabloları Dışında Otomatik", + "Manual" => "El ile", + "Sort &\nFilter" => "Sırala ve\nFiltre Uygula", + "Sort" => "Sırala", + "Sort A to Z" => "A'dan Z'ye Sırala", + "Sort Z to A" => "Z'den A'ya Sırala", + "Custom Sort…" => "Özel Sıralama…", + "Filter" => "Filtre", + "Reapply" => "Yeniden Uygula", + "Get Data\n(Text/CSV)" => "Veri Al\n(Metin/CSV)", + "Remove\nDuplicates" => "Yinelenenleri\nKaldır", + "Data\nValidation" => "Veri\nDoğrulama", + "Data Validation" => "Veri Doğrulama", + "Data Validation…" => "Veri Doğrulama…", + "Circle Invalid Data" => "Geçersiz Verileri Daire İçine Al", + "Clear Validation" => "Doğrulamayı Temizle", + "Group" => "Gruplandır", + "Ungroup" => "Grubu Çöz", + "Show Detail" => "Ayrıntıyı Göster", + "Hide Detail" => "Ayrıntıyı Gizle", + "Subtotal" => "Alt Toplam", + "What-If\nAnalysis" => "Durum\nÇözümlemesi", + "Data Table…" => "Veri Tablosu…", + "Scenario Manager…" => "Senaryo Yöneticisi…", + "Goal Seek…" => "Hedef Ara…", + "Spelling" => "Yazım Denetimi", + "Check\nAccessibility" => "Erişilebilirliği\nDenetle", + "Threaded Comments" => "Yanıtlanan Açıklamalar", + "Show\nComments" => "Açıklamaları\nGöster", + "Protect\nWorkbook" => "Çalışma Kitabını\nKoru", + "Unprotect\nSheet" => "Sayfa Korumasını\nKaldır", + "Protect Sheet…" => "Sayfayı Koru…", + "Comments" => "Açıklamalar", + "Notes" => "Notlar", + "Page Break\nPreview" => "Sayfa Sonu\nÖnizleme", + "Page\nLayout" => "Sayfa\nDüzeni", + "Formula Bar" => "Formül Çubuğu", + "Gridlines" => "Kılavuz Çizgileri", + "Headings" => "Başlıklar", + "Zoom" => "Yakınlaştır", + "Forecast\nSheet" => "Tahmin\nSayfası", + "Add footer" => "Altbilgi ekle", + "Add header" => "Üstbilgi ekle", + "Click to add data" => "Veri eklemek için tıklayın", + "Current Date" => "Geçerli Tarih", + "Current\nDate" => "Geçerli\nTarih", + "Current\nTime" => "Geçerli\nSaat", + "Current Time" => "Geçerli Saat", + "File Name" => "Dosya Adı", + "File\nName" => "Dosya\nAdı", + "File\nPath" => "Dosya\nYolu", + "File Path" => "Dosya Yolu", + "Footer" => "Altbilgi", + "Different First Page" => "İlk Sayfada Farklı", + "Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı", + "Go to Footer" => "Altbilgiye Git", + "Go to Header" => "Üstbilgiye Git", + "Go to\nFooter" => "Altbilgiye\nGit", + "Go to\nHeader" => "Üstbilgiye\nGit", + "Header" => "Üstbilgi", + "(none)" => "(yok)", + "Number\nof Pages" => "Sayfa\nSayısı", + "Number of Pages" => "Sayfa Sayısı", + "Page\nNumber" => "Sayfa\nNumarası", + "Page Number" => "Sayfa Numarası", + "Ruler" => "Cetvel", + "Sheet Name" => "Sayfa Adı", + "Sheet\nName" => "Sayfa\nAdı", + "Animations" => "Animasyonlar", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Seçim çerçevesini atlamak yerine taşındığınız hücreye kaydırarak hareket ettir" + } + "Insert Copied Cells" => "Kopyalanan Hücreleri Ekle", + "Insert Copied Cells…" => "Kopyalanan Hücreleri Ekle…", + "Insert Cut Cells" => "Kesilen Hücreleri Ekle", + "Insert Paste" => "Ekleyerek Yapıştır", + "Previous" => "Önceki", + "Next" => "Sonraki", + "Previous Comment" => "Önceki Açıklama", + "Next Comment" => "Sonraki Açıklama", + "Show All Notes" => "Tüm Notları Göster", + "Convert to Comments" => "Açıklamalara Dönüştür", + "Checkbox" => "Onay Kutusu", + "Insert checkboxes in the selected cells" => "Seçili hücrelere onay kutuları ekle", + "Sheet\nRight-to-Left" => "Sayfa\nSağdan Sola", + "Sheet Right-to-Left" => "Sayfa Sağdan Sola", + "Data\nAnalysis" => "Veri\nAnalizi", + "Data Analysis" => "Veri Analizi", + "Solver" => "Çözücü", + "Allow Edit\nRanges" => "Aralıkları Düzenlemeye\nİzin Ver", + "Allow Edit Ranges" => "Aralıkları Düzenlemeye İzin Ver", + "Drag to freeze columns" => "Sütunları dondurmak için sürükleyin", + "Drag to freeze rows" => "Satırları dondurmak için sürükleyin", + "Select\nData" => "Veri\nSeç", + "File" => "Dosya", + "Back" => "Geri", + "New" => "Yeni", + "Open" => "Aç", + "Info" => "Bilgi", + "Save" => "Kaydet", + "Save As" => "Farklı Kaydet", + "Export" => "Dışarı Aktar", + "Close" => "Kapat", + "Options" => "Seçenekler", + "Blank workbook" => "Boş çalışma kitabı", + "Recent" => "Son Kullanılanlar", + "No recent workbooks yet." => "Henüz son kullanılan çalışma kitabı yok.", + "More workbooks" => "Diğer çalışma kitapları", + "Modified" => "Değiştirilme Tarihi", + "Not available" => "Kullanılamıyor", + "Just now" => "Az önce", + "Yesterday" => "Dün", + "{n} min ago" => "{n} dk. önce", + "{n} h ago" => "{n} sa. önce", + "{n} days ago" => "{n} gün önce", + "{n} weeks ago" => "{n} hafta önce", + "{n} months ago" => "{n} ay önce", + "{n} years ago" => "{n} yıl önce", + "Browse…" => "Gözat…", + "Not saved yet" => "Henüz kaydedilmedi", + "Location" => "Konum", + "Sheets" => "Sayfalar", + "Protect Workbook" => "Çalışma Kitabını Koru", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Sayfa yapısı kilitli: sayfalar eklenemez, taşınamaz, yeniden adlandırılamaz veya silinemez." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Sayfaların eklenmesini, taşınmasını, yeniden adlandırılmasını veya silinmesini önlemek için sayfa yapısını kilitleyin." + } + "Properties" => "Özellikler", + "Title" => "Başlık", + "Subject" => "Konu", + "Author" => "Yazar", + "Company" => "Şirket", + "Keywords" => "Anahtar Sözcükler", + "Description" => "Açıklama", + "No workbook is open." => "Açık çalışma kitabı yok.", + "Save as type" => "Kayıt türü", + "Excel Workbook (*.xlsx)" => "Excel Çalışma Kitabı (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (Virgülle ayrılmış) (*.csv)", + "Web Page (*.html)" => "Web Sayfası (*.html)", + "PDF (*.pdf)" => "PDF Belgesi (*.pdf)", + "Download" => "İndir", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "CSV dosyası, web sayfası veya PDF bir kopyadır: çalışma kitabı kendi dosyasını korur." + } + "Save the workbook under another name or in another folder." => "Çalışma kitabını başka bir adla veya başka bir klasöre kaydedin.", + "Print Active Sheets" => "Etkin Sayfaları Yazdır", + "Print Entire Workbook" => "Tüm Çalışma Kitabını Yazdır", + "Fit Sheet on One Page" => "Sayfayı Bir Sayfaya Sığdır", + "Page Setup…" => "Sayfa Yapısı…", + "Page {page} of {pages}" => "Sayfa {page} / {pages}", + "Nothing to print." => "Yazdırılacak bir şey yok.", + "Previous page" => "Önceki sayfa", + "Next page" => "Sonraki sayfa", + "Create PDF" => "PDF Oluştur", + "Save the sheet as a PDF document that keeps its page layout." => "Sayfayı, sayfa düzenini koruyan bir PDF belgesi olarak kaydedin.", + "Change File Type" => "Dosya Türünü Değiştir", + "CSV (Comma delimited)" => "CSV (Virgülle ayrılmış)", + "Save the active sheet as comma-separated text." => "Etkin sayfayı virgülle ayrılmış metin olarak kaydedin.", + "Web Page (.html)" => "Web Sayfası (.html)", + "Save the workbook as a web page." => "Çalışma kitabını web sayfası olarak kaydedin.", + "Options|General" => "Genel", + "Options|Save" => "Kaydet", + "Show formula bar" => "Formül çubuğunu göster", + "Enable AutoComplete for cell values" => "Hücre değerleri için Otomatik Tamamla'yı etkinleştir", + "Save .xlsx workbooks automatically after every change" => ".xlsx çalışma kitaplarını her değişiklikten sonra otomatik olarak kaydet", + "Workbook calculation" => "Çalışma kitabı hesaplaması", + "Enable iterative calculation" => "Yinelemeli hesaplamayı etkinleştir", + "Calculation settings are saved with the workbook." => "Hesaplama ayarları çalışma kitabıyla birlikte kaydedilir.", + "Interface size" => "Arayüz boyutu", + "Auto" => "Otomatik", + "100%" => "%100", + "Zoom to\nSelection" => "Seçimi\nYakınlaştır", + "Freeze\nPanes" => "Bölmeleri\nDondur", + "Freeze Panes" => "Bölmeleri Dondur", + "Freeze Top Row" => "Üst Satırı Dondur", + "Freeze First Column" => "İlk Sütunu Dondur", + "Unfreeze Panes" => "Bölmeleri Çöz", + "Dark Mode" => "Koyu Mod", + "Interface language" => "Arayüz dili", + "Command\nPalette" => "Komut\nPaleti", + "Agent\nControl" => "Ajan\nKontrolü", + "Action\nJournal" => "İşlem\nGünlüğü", + "About\nGridCraft" => "GridCraft\nHakkında", + "Share" => "Paylaş", + "Add Chart\nElement" => "Grafik Öğesi\nEkle", + "Change\nChart Type" => "Grafik Türünü\nDeğiştir", + "Switch\nRow/Column" => "Satır/Sütun\nDeğiştir", + "Data Labels: None" => "Veri Etiketleri: Yok", + "Data Labels: Show" => "Veri Etiketleri: Göster", + "Legend: None" => "Gösterge: Yok", + "Legend: Bottom" => "Gösterge: Alt", + "Legend: Top" => "Gösterge: Üst", + "Legend: Right" => "Gösterge: Sağ", + "Gridlines: None" => "Kılavuz Çizgileri: Yok", + "Gridlines: Show" => "Kılavuz Çizgileri: Göster", + "Chart\nTitle" => "Grafik\nBaşlığı", + "Delete\nChart" => "Grafiği\nSil", + "Convert\nto Range" => "Aralığa\nDönüştür", + "Table\nStyles" => "Tablo\nStilleri", + "Top 10 Items" => "İlk 10 Öğe", + "Top 10%" => "İlk %10", + "Bottom 10 Items" => "Son 10 Öğe", + "Bottom 10%" => "Son %10", + "Above Average" => "Ortalamanın Üstünde", + "Below Average" => "Ortalamanın Altında", + "Clear Rules from Entire Sheet" => "Kuralları Tüm Sayfadan Temizle", + "Clear Rules from Selected Cells" => "Kuralları Seçili Hücrelerden Temizle", + "Constants" => "Sabitler", + "Data Bars" => "Veri Çubukları", + "Color Scales" => "Renk Ölçekleri", + "Icon Sets" => "Simge Kümeleri", + "Home" => "Giriş", + "Draw" => "Çiz", + "Page Layout" => "Sayfa Düzeni", + "Formulas" => "Formüller", + "Data" => "Veri", + "Review" => "Gözden Geçir", + "View" => "Görünüm", + "Automate" => "Otomatikleştir", + "Table Design" => "Tablo Tasarımı", + "Chart Design" => "Grafik Tasarımı", + "Ready" => "Hazır", + "Enter" => "Giriş", + "Edit" => "Düzenle", + "Point" => "İşaretle", + "Calculate" => "Hesapla", + "Select destination and press Enter or choose Paste" => "Hedefi seçip Enter tuşuna basın veya Yapıştır'ı seçin", + "General" => "Genel", + "Number" => "Sayı", + "Currency" => "Para Birimi", + "Accounting" => "Muhasebe", + "Date" => "Tarih", + "Time" => "Saat", + "Percentage" => "Yüzde", + "Fraction" => "Kesir", + "Scientific" => "Bilimsel", + "Text" => "Metin", + "Special" => "Özel", + "Custom" => "İsteğe Uyarlanmış", + "Short Date" => "Kısa Tarih", + "Long Date" => "Uzun Tarih", + "More Number Formats…" => "Diğer Sayı Biçimleri…", + "Financial" => "Finansal", + "Logical" => "Mantıksal", + "Date &\nTime" => "Tarih ve\nSaat", + "Lookup &\nReference" => "Arama ve\nBaşvuru", + "Math &\nTrig" => "Matematik ve\nTrigonometri", + "More\nFunctions" => "Diğer\nİşlevler", + "Page Layout|Orientation" => "Sayfa Yönü", + "Display theme" => "Görüntü teması", + "System" => "Sistem", + "Light" => "Açık", + "Dark" => "Koyu", + "Sheet Options|View" => "Görünüm", + "Print" => "Yazdır", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f549aec2b..74a09d536 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -654,7 +654,9 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")), + None => Err(format!( + "unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\", \"de\", \"cs\", \"fr\" or \"tr\")" + )), } } "app.language.english" => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 08097ba27..613814d03 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -837,7 +837,7 @@ mod tests { ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] ); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] { + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); } } diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index 855869377..16f4dac15 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -219,3 +219,18 @@ fn user_defined_prefix_is_hidden_while_editing_and_kept_on_commit() { assert!(app.commit_edit(0, 0, false, false)); assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); } + +#[test] +fn turkish_interface_retains_its_canonical_formula_fallback() { + let mut app = app(); + app.run("app.language.set", json!({"code":"tr-TR"})).unwrap(); + assert_eq!(app.ui.language, Language::Tr); + assert_eq!(app.ui.language.tr("Data"), "Veri"); + assert_eq!(app.ui.language.tr("Formulas"), "Formüller"); + assert!(enter(&mut app, "=SUM(1.5,2.25)")); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); + assert!(!enter(&mut app, "=TOPLA(1;2)")); + let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Tr); +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 02084ffb8..8bbdc5333 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -364,6 +364,8 @@ fn language_switch_translates_the_ribbon_and_persists() { (Language::Ru, "Данные"), (Language::PtBr, "Dados"), (Language::De, "Daten"), + (Language::Fr, "Données"), + (Language::Tr, "Veri"), ] { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 64876633d..9109311f9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -60,8 +60,9 @@ separators, number/date formats, machine translation with agent review, and scri | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | | Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | +| Turkish | tr | 382 (~17%) | no | Latin: yes | yes | menus only | 4–8 h | -Other shipped languages: Russian (ru) and German (de), 266 labels each (~12%), menus only. +Other shipped languages: Russian (ru), German (de), Czech (cs), French (fr) and Turkish (tr), menus only. **Total:** catalog work 10–20 h + languages 92–144 h ≈ **100–165 h**. Parallelizes almost fully once the catalog covers dialogs and commands. **Needs a human:** native-speaker review of every @@ -89,6 +90,7 @@ language (none so far) and of localized function names. | 2026-10-19 | trivial | Text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`) on XLSX save, so Excel-authored Arabic and Hebrew text boxes reopen right to left | | 2026-10-19 | minor | Save as Web Page and copied HTML mark right-to-left sheets `dir="rtl"`, let General-aligned Arabic and Hebrew text align by its own direction (`dir="auto"`), and keep numbers on right-to-left sheets left to right and on the left | | 2026-10-19 | minor | French and Serbian kept the context in three `context\|label` translations ("Options\|Général", "Opcije\|Opšte" for `Options\|General`; also `Options\|Save`, `Sheet Options\|View`). `tr` shows a found translation as it is, so File › Options headings and the Sheet Options "View" checkboxes showed the prefix. Fixed; a test checks that no catalog shows a `\|` | +| 2026-10-12 | minor | Turkish ribbon catalog (`i18n/tr.rs`, 382 labels); `tr` locale tags (tr-TR, tr_TR…) resolve to Turkish | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | From 6735e9e7c0b88faec1b4ee6cfd691b3864bc4bc7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:34:58 +0000 Subject: [PATCH 26/30] Integration: #451 qualify the run-stop row through value_ref A stored cell with no value and no formula falls through to spill in value_ref; the scan now asks value_ref for the row where the stored run stops. Docs corrected; tests cover a style-only stored cell over a spill value. --- crates/model/src/sheet.rs | 35 ++++++++++++++++++++++++++--------- crates/model/src/store.rs | 7 +++---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 9833024b5..5a75c8f56 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -188,7 +188,7 @@ impl Sheet { /// 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 + /// a per-row `value_ref` 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 { @@ -203,10 +203,10 @@ impl Sheet { 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()); + // The run stopped short of `row`: ask `value_ref` for this one row, which also + // covers a stored cell with no value and no formula (a formatted spill child) + // falling through to `spill`. + let qualifies = self.value_ref(c).is_some_and(|v| !v.is_empty()); if qualifies { row += 1; } else { @@ -505,6 +505,12 @@ mod tests { // Stored but blank: does not count (matches `has_value`/`value_ref`). s.set_value(CellRef::new(r, 0), Value::Text("".into())); } + 4 if rng.below(2) == 0 => { + // A style-only stored cell over a spill value: `value_ref` falls through + // to the spill value, so the row counts. + s.spill.insert(CellRef::new(r, 0), Value::Number(r as f64)); + s.set_cell(CellRef::new(r, 0), Cell { style: StyleId(1), ..Default::default() }); + } _ => {} // gap } } @@ -536,16 +542,27 @@ mod tests { 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). + // row 2: a formula (e.g. `=""`) whose cached value is empty. It does not count, same as + // `value_ref(...).is_some_and(|v| !v.is_empty())` says for it, even though it has a + // formula (unlike `CellStore::has`, used for Ctrl+arrow navigation, which counts it). 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_a_style_only_stored_row() { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 1), Value::Number(1.0)); + // row 1: a formatted spill child: stored with a style only, value from `spill`. + s.spill.insert(CellRef::new(1, 1), Value::Number(2.0)); + s.set_cell(CellRef::new(1, 1), Cell { style: StyleId(1), ..Default::default() }); + s.set_value(CellRef::new(2, 1), Value::Number(3.0)); + assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 2); + assert_eq!(s.last_contiguous_nonempty(1, 1, 100), 2); + } + #[test] fn last_contiguous_nonempty_spill_extends_an_unstored_row() { let mut s = Sheet::new("S"); diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index 658b898bf..10eceb822 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -132,10 +132,9 @@ impl CellStore { /// 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. + /// them. A stored cell with no value and no formula (for example a formatted spill child) does + /// fall through to `spill` in `value_ref`, so `last_contiguous_nonempty` asks `value_ref` + /// about the row where this run stops. /// /// 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 From 6432253de980c20995df5871d6ebeda288201493 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:36:40 +0000 Subject: [PATCH 27/30] Integration: #973 move the sheet-tab copy row from the element table to the change history --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ecbbfdeec..0a02a7a5f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -14,7 +14,6 @@ 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); 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) | @@ -162,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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) | | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 2026-10-13 | trivial | More Colors… and Recent Colors (#928) | From c37298b46857725ca4464c69ec234995e6c18559 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:40:38 +0000 Subject: [PATCH 28/30] Integration: #1151 test literal gains the category_levels field from #1088 --- crates/chart/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 134ee5f64..7860f4af7 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1170,7 +1170,7 @@ fn a_huge_trendline_forecast_does_not_collapse_the_columns() { 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 d = ChartData { palette: vec![], category_levels: 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); From f02f4469055e23ec0434583c0f1f5f16bd558c1a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:45:54 +0000 Subject: [PATCH 29/30] Integration: #1194 drop the duplicate Cut and Copy German arms (#1830 already translates them) --- crates/ui-egui/src/i18n/de.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 1f390b302..029082c7a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -10,8 +10,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponieren", "Paste Link" => "Verknüpfung einfügen", "Paste Special…" => "Inhalte einfügen…", - "Cut" => "Ausschneiden", - "Copy" => "Kopieren", "Format Painter (double-click to keep it on)" => "Format übertragen (Doppelklick zum Beibehalten)", "Bold" => "Fett", "Italic" => "Kursiv", From 55877df80fcc607651af210f698e5817e2c1ff97 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:46:00 +0000 Subject: [PATCH 30/30] Integration: cargo fmt --- crates/ui-egui/src/grid.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 76a9bcf68..ac569b5d6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3214,7 +3214,10 @@ 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(height).min_scrolled_height(height).show(ui, |ui| { for it in &items { - if ui.add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))).clicked() { + if ui + .add(egui::Button::new(it.as_str()).frame_when_inactive(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; }