From 79e85c1b806c6581ab2ab4b873a8ae204d9b7644 Mon Sep 17 00:00:00 2001
From: Harrison O'Connor-Hoover
<130776285+harrisonoconnorhover@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:38:13 -0400
Subject: [PATCH 01/46] fix(edit): recalculate every cell changed by large
clears (#1032) (#1128)
---
crates/engine/src/cmd/edit.rs | 25 +++++-
.../engine/tests/sparse_clear_invalidation.rs | 87 +++++++++++++++++++
docs/function-parity.md | 4 +-
3 files changed, 111 insertions(+), 5 deletions(-)
create mode 100644 crates/engine/tests/sparse_clear_invalidation.rs
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 82dbe0be7..c69bac208 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -1246,6 +1246,9 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result {
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
+ // Report every stored cell we touch, even beyond a million coordinates in a sparse
+ // selection. Removed formula anchors must also reach calc so their spills disappear.
+ cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
@@ -1284,10 +1287,24 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "formats") {
- sh.merges.retain(|m| !r.contains_range(m));
- }
- for c in r.iter().take(1_000_000) {
- cx.changed.push((sheet, c));
+ let mut unmerged = false;
+ sh.merges.retain(|m| {
+ let keep = !r.contains_range(m);
+ unmerged |= !keep;
+ keep
+ });
+ if unmerged {
+ // An empty merge can block a spill without storing any cells in this range.
+ // As in Unmerge, retry blocked formula anchors that could reach the freed area.
+ let reach = RangeRef::new(CellRef::new(0, 0), r.end);
+ let blocked: Vec<_> = sh
+ .cells
+ .iter_range(reach)
+ .filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
+ .map(|(c, _)| (sheet, c))
+ .collect();
+ cx.changed.extend(blocked);
+ }
}
}
Ok(Json::Null)
diff --git a/crates/engine/tests/sparse_clear_invalidation.rs b/crates/engine/tests/sparse_clear_invalidation.rs
new file mode 100644
index 000000000..3ac8f77e3
--- /dev/null
+++ b/crates/engine/tests/sparse_clear_invalidation.rs
@@ -0,0 +1,87 @@
+//! Clearing a large sparse selection must invalidate every changed cell (issue #1032).
+
+use gridcraft_core::{CellError, CellRef, Value};
+use gridcraft_engine::Session;
+use serde_json::json;
+
+fn value(s: &Session, address: &str) -> Value {
+ s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(address).unwrap())
+}
+
+#[test]
+fn large_clear_updates_distant_direct_and_transitive_dependents() {
+ for command in ["edit.clearContents", "edit.clearAll"] {
+ let mut s = Session::new();
+ s.new_workbook();
+ for (cell, input) in [("A1000001", "7"), ("B1", "=A1000001"), ("C1", "=B1*2")] {
+ s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
+ }
+ assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)));
+
+ // B1 stretches the used range to row 1; the changed source lies just past the old cap.
+ s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
+ assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
+ assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command}");
+
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(value(&s, "A1000001"), Value::Number(7.0), "{command} undo");
+ assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)), "{command} undo");
+ s.execute("edit.redo", json!({})).unwrap();
+ assert_eq!(value(&s, "A1000001"), Value::Empty, "{command} redo");
+ assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command} redo");
+ }
+}
+
+#[test]
+fn large_clear_removes_a_distant_formulas_spill_and_updates_its_dependent() {
+ for command in ["edit.clearContents", "edit.clearAll"] {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1000001", "input": "=SEQUENCE(2)"})).unwrap();
+ s.execute("cell.set", json!({"cell": "B1", "input": "=A1000002"})).unwrap();
+ assert_eq!((value(&s, "A1000002"), value(&s, "B1")), (Value::Number(2.0), Value::Number(2.0)));
+
+ // The spill lies outside the selection, but must disappear when its anchor is cleared.
+ s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
+ assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
+ assert_eq!(value(&s, "A1000002"), Value::Empty, "{command}");
+ assert_eq!(value(&s, "B1"), Value::Number(0.0), "{command}");
+ }
+}
+
+#[test]
+fn clearing_an_empty_merge_unblocks_a_spill() {
+ for command in ["edit.clearFormats", "edit.clearAll"] {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
+ // Keep the empty merged area inside the used range after the spill becomes blocked.
+ s.execute("cell.set", json!({"cell": "C4", "input": "1"})).unwrap();
+ s.execute("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
+ assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
+
+ s.execute(command, json!({"range": "A2:B3"})).unwrap();
+ assert_eq!((value(&s, "A1"), value(&s, "A3")), (Value::Number(1.0), Value::Number(3.0)), "{command}");
+ assert!(s.doc().unwrap().wb.active().unwrap().merges.is_empty(), "{command}");
+ }
+}
+
+#[test]
+fn clearing_a_distant_styled_blocker_unblocks_a_spill() {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(100)"})).unwrap();
+ s.execute("cell.set", json!({"cell": "A90", "input": "x"})).unwrap();
+ s.execute("home.bold", json!({"range": "A90", "on": true})).unwrap();
+ assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
+
+ // The cleared blocker stays stored for its style and lies beyond the local 64-row scan.
+ s.execute("edit.clearContents", json!({"range": "A2:A100"})).unwrap();
+ assert_eq!(value(&s, "A1"), Value::Number(1.0));
+ assert_eq!(value(&s, "A90"), Value::Number(90.0));
+ assert_eq!(value(&s, "A100"), Value::Number(100.0));
+ let wb = &s.doc().unwrap().wb;
+ let cell = wb.active().unwrap().cell(CellRef::parse("A90").unwrap()).unwrap();
+ assert_eq!(cell.value, Value::Empty);
+ assert!(wb.styles.get(cell.style).font.bold);
+}
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..274800699 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -76,6 +76,7 @@ PIVOTBY, REGEXTEST…) are all present.
| Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done |
| Defined names (workbook and sheet scope), Create from Selection | done |
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
+| Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) |
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
@@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
From 82ee02aac475e6e217b0dba3914d5b7f8081f6f1 Mon Sep 17 00:00:00 2001
From: Nihilist Narwhal
Date: Thu, 15 Oct 2026 14:39:28 -0500
Subject: [PATCH 02/46] Formula parser: Excel's 8,192-character limit and
postfix nesting (never crash) (#1330)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A hostile file could abort any process that opens it: a cell formula of a
million `%` (`1%%%…`) or a 200,000-term sum parsed into trees deep enough to
overflow the stack when printed, saved, evaluated or dropped, even in release
builds. Postfix operators (`%`, invoking a result) built their nesting in a
loop that the 256-level limit did not see.
- `parse` refuses formula text longer than Excel's 8,192 characters
(`MAX_FORMULA_LEN`, counted in characters, the leading `=` excluded). This
also bounds the depth of the left-deep trees long operator chains build.
- Each postfix `%` and each call of a result counts towards the nesting limit.
- A file's formula that is refused keeps its text and cached value, as other
unparsable formulas do.
Regression tests: formula (small-stack thread: long `%`, invoke and operator
chains are errors; the limit counts characters, not bytes) and xlsx
(`hostile_formula_text_does_not_overflow_the_stack`: open, save and drop a
workbook with such formulas and a defined name on a 1 MiB stack). Both abort
with a stack overflow without the fix.
Co-authored-by: Claude Opus 5.5
---
crates/formula/src/lib.rs | 2 +-
crates/formula/src/parser.rs | 19 ++++++++++++++++
crates/formula/src/tests.rs | 35 ++++++++++++++++++++++++++++++
crates/xlsx/src/tests/malformed.rs | 29 +++++++++++++++++++++++++
docs/file-format-parity.md | 3 ++-
5 files changed, 86 insertions(+), 2 deletions(-)
diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs
index e193c3fb4..88d09a8af 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::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse};
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 3ea97085e..555717be9 100644
--- a/crates/formula/src/parser.rs
+++ b/crates/formula/src/parser.rs
@@ -17,6 +17,11 @@ pub struct ParseError {
pub(crate) const MAX_DEPTH: usize = 256;
+/// Longest formula accepted, in characters: Excel's limit for formula contents. It also bounds
+/// the depth of the trees long operator chains build (`1+1+…`), which dropping and evaluating
+/// walk recursively; without it a formula of a few hundred kilobytes in a file overflows the stack.
+pub const MAX_FORMULA_LEN: usize = 8_192;
+
/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped
/// for `=`.
pub fn parse(src: &str) -> Result {
@@ -25,6 +30,9 @@ pub fn parse(src: &str) -> Result {
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result {
let body = src.strip_prefix('=').unwrap_or(src);
+ if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN {
+ return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 });
+ }
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 };
@@ -160,11 +168,18 @@ impl Parser {
return Err(self.err("formula is nested too deeply"));
}
let mut lhs = self.prefix(allow_union)?;
+ // Postfix operators (`%`, invoking a result) wrap `lhs` without recursing: they count
+ // towards the nesting limit, so `1%%%…` can't build a tree deeper than it.
+ let mut postfix = 0;
loop {
if *self.peek() == Tok::Op("%") {
if 70 < min_bp {
break;
}
+ postfix += 1;
+ if self.depth + postfix > MAX_DEPTH {
+ return Err(self.err("formula is nested too deeply"));
+ }
self.next();
lhs = Expr::Unary(UnOp::Percent, Box::new(lhs));
continue;
@@ -174,6 +189,10 @@ impl Parser {
if let Expr::Name(_) = lhs {
// A name followed by '(' was lexed as Func; this is `(name)(…)` only.
}
+ postfix += 1;
+ if self.depth + postfix > MAX_DEPTH {
+ return Err(self.err("formula is nested too deeply"));
+ }
self.next();
let args = self.args()?;
lhs = Expr::Invoke(Box::new(lhs), args);
diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs
index 103a26bd1..d23eba0e2 100644
--- a/crates/formula/src/tests.rs
+++ b/crates/formula/src/tests.rs
@@ -366,3 +366,38 @@ fn prints_long_flat_chain_without_deep_recursion() {
assert_eq!(text, vec!["1"; 20001].join("+"));
assert_eq!(r1c1, text);
}
+
+#[test]
+fn hostile_formulas_are_errors_not_stack_overflows() {
+ // A small stack: parsing must fail cleanly instead of building trees deeper than the nesting
+ // limit, which dropping, printing and evaluating walk recursively.
+ let handle = std::thread::Builder::new()
+ .stack_size(512 * 1024)
+ .spawn(|| {
+ let percents = format!("1{}", "%".repeat(1_000));
+ let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000));
+ let long_chain = format!("1{}", "+1".repeat(100_000));
+ let huge_percents = format!("1{}", "%".repeat(1_000_000));
+ (parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err())
+ })
+ .expect("spawn");
+ assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true));
+ // Within the limits these still parse.
+ assert!(parse(&format!("1{}", "%".repeat(100))).is_ok());
+ assert!(parse("LAMBDA(x,x)(1)(2)").is_ok());
+}
+
+#[test]
+fn formulas_longer_than_excel_allows_are_refused() {
+ let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2));
+ assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1);
+ assert!(parse(&at_limit).is_ok());
+ assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count");
+ let over = format!("{at_limit}+1");
+ let err = parse(&over).unwrap_err();
+ assert!(err.msg.contains("8192"), "{}", err.msg);
+ // Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit.
+ let text = format!("\"{}\"", "é".repeat(8_000));
+ assert!(text.len() > crate::MAX_FORMULA_LEN);
+ assert!(parse(&text).is_ok());
+}
diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs
index 5e47c1f4b..d029acaa4 100644
--- a/crates/xlsx/src/tests/malformed.rs
+++ b/crates/xlsx/src/tests/malformed.rs
@@ -227,3 +227,32 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3);
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
}
+
+/// Formula text built to overflow a recursive parser or tree walk (a million `%`, a 200,000-term
+/// sum): the cells keep their formula text and cached value, nothing overflows the stack.
+#[test]
+fn hostile_formula_text_does_not_overflow_the_stack() {
+ let percents = format!("1{}", "%".repeat(1_000_000));
+ let chain = format!("1{}", "+1".repeat(100_000));
+ let body = format!(r#"{percents}3{chain}4
"#);
+ let names = format!(r#"{percents}"#);
+ let bytes = minimal(&body, &[], "", &names);
+ let handle = std::thread::Builder::new()
+ .stack_size(1024 * 1024)
+ .spawn(move || {
+ let (wb, _) = read_xlsx(&bytes).expect("readable");
+ let s = wb.sheet(0).expect("sheet");
+ let a1 = s.cell(gridcraft_core::CellRef::new(0, 0)).and_then(|c| c.formula.clone()).expect("formula kept");
+ let b1 = s.cell(gridcraft_core::CellRef::new(0, 1)).and_then(|c| c.formula.clone()).expect("formula kept");
+ let kept = (a1.expr.is_none(), b1.expr.is_none(), a1.text.len(), s.value(gridcraft_core::CellRef::new(0, 0)));
+ assert_eq!(wb.names.first().map(|n| n.formula.len()), Some(1_000_001));
+ let out = write_xlsx(&wb).expect("writable");
+ drop(wb);
+ (kept, !out.is_empty())
+ })
+ .expect("spawn");
+ let ((a_unparsed, b_unparsed, a_len, a_value), written) = handle.join().expect("no stack overflow");
+ assert!(a_unparsed && b_unparsed && written);
+ assert_eq!(a_len, 1_000_001);
+ assert_eq!(a_value, gridcraft_core::Value::Number(3.0));
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 988472414..158149fb3 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
From fda8e7b703dbe24f9fc09fc98c00ef1307577e7f Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 16 Oct 2026 01:04:00 +0200
Subject: [PATCH 03/46] AutoFilter: Enter in the filter menu applies the filter
and closes it (fixes #1516)
---
crates/ui-egui/src/grid.rs | 25 +++++--
crates/ui-egui/tests/filter_menu_enter.rs | 87 +++++++++++++++++++++++
2 files changed, 107 insertions(+), 5 deletions(-)
create mode 100644 crates/ui-egui/tests/filter_menu_enter.rs
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 97b787ef3..00e0c18be 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -2573,7 +2573,8 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
resp.request_focus();
}
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
- if editing || app.dialog.is_some() || other_focus || app.message.is_some() {
+ // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516).
+ if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() {
return;
}
if ctx.memory(|m| m.focused()).is_none() {
@@ -2768,16 +2769,24 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
let key = egui::Id::new(("filter_sel", col));
let search_key = egui::Id::new(("filter_search", col));
+ let buttons_key = egui::Id::new(("filter_buttons", col));
+ // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its
+ // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click.
+ let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default();
+ let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f));
+ let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let mut close = false;
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
let response = area.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(230.0);
- if ui.button("↑ Sort Ascending").clicked() {
+ let ascending = ui.button("↑ Sort Ascending");
+ if ascending.clicked() {
let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)}));
close = true;
}
- if ui.button("↓ Sort Descending").clicked() {
+ let descending = ui.button("↓ Sort Descending");
+ if descending.clicked() {
let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)}));
close = true;
}
@@ -2814,11 +2823,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
ui.separator();
ui.horizontal(|ui| {
- if ui.button("Clear Filter").clicked() {
+ let clear = ui.button("Clear Filter");
+ if clear.clicked() {
let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true}));
close = true;
}
- if ui.button("Apply").clicked() {
+ // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then.
+ let any = checks.iter().any(|(_, on)| *on);
+ let apply = ui.button("Apply");
+ ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id]));
+ if apply.clicked() || (enter && any) {
let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on);
let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect();
let p = if checks.iter().all(|(_, b)| *b) {
@@ -2836,6 +2850,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
ui.ctx().data_mut(|d| {
d.remove::>(key);
d.remove::(search_key);
+ d.remove::>(buttons_key);
});
app.grid.filter_menu = None;
}
diff --git a/crates/ui-egui/tests/filter_menu_enter.rs b/crates/ui-egui/tests/filter_menu_enter.rs
new file mode 100644
index 000000000..bd45a2cc1
--- /dev/null
+++ b/crates/ui-egui/tests/filter_menu_enter.rs
@@ -0,0 +1,87 @@
+//! Enter in the AutoFilter menu works like its Apply button, as Excel's OK (issue #1516): from the
+//! search box or with a value focused it applies the filter and closes the menu, and the key never
+//! reaches the grid underneath.
+
+use egui::{Event, Key, Modifiers, PointerButton};
+use egui_kittest::kittest::Queryable;
+use gridcraft_engine::Session;
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+type Harness = egui_kittest::Harness<'static, SheetApp>;
+
+/// A1:A4 holds a header and three fruits under an AutoFilter, with the menu opened by Alt+Down on A1.
+fn filter_menu_open() -> Harness {
+ let mut session = Session::new();
+ session.new_workbook();
+ session.execute("range.setValues", json!({"range": "A1", "values": [["Fruit"], ["Apple"], ["Banana"], ["Cherry"]]})).unwrap();
+ session.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
+ session.execute("selection.set", json!({"cell": "A1"})).unwrap();
+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+ |ui, app: &mut SheetApp| {
+ app.logic(ui.ctx());
+ app.ui(ui);
+ },
+ SheetApp::new(session, Default::default()),
+ );
+ h.run_steps(4);
+ key(&mut h, Key::ArrowDown, Modifiers::ALT);
+ assert!(h.state().grid.filter_menu.is_some(), "Alt+Down opens the filter menu");
+ h
+}
+
+fn key(h: &mut Harness, k: Key, m: Modifiers) {
+ h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
+ h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
+ h.run_steps(2);
+}
+
+/// Rows 2..=4 (Apple, Banana, Cherry): which ones the filter hides.
+fn hidden(h: &Harness) -> Vec {
+ let sh = h.state().session.active().unwrap().wb.active().unwrap();
+ (1..=3).map(|r| sh.is_row_hidden(r)).collect()
+}
+
+fn active(h: &Harness) -> String {
+ h.state().session.active().unwrap().selection.active.a1()
+}
+
+#[test]
+fn enter_in_the_search_box_applies_the_filter() {
+ let mut h = filter_menu_open();
+ h.input_mut().events.push(Event::Text("an".into()));
+ h.run_steps(2);
+ key(&mut h, Key::Enter, Modifiers::NONE);
+ assert_eq!(hidden(&h), [true, false, true], "only Banana matches the search");
+ assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu");
+ assert_eq!(active(&h), "A1", "Enter must not move the active cell");
+}
+
+#[test]
+fn enter_with_a_value_clicked_applies_the_filter() {
+ let mut h = filter_menu_open();
+ let apple = h.get_by_label("Apple").rect().center();
+ h.input_mut().events.push(Event::PointerMoved(apple));
+ for pressed in [true, false] {
+ h.input_mut().events.push(Event::PointerButton { pos: apple, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
+ }
+ h.run_steps(2);
+ assert!(h.state().grid.filter_menu.is_some(), "clicking a value keeps the menu open");
+ key(&mut h, Key::Enter, Modifiers::NONE);
+ // Enter applies the list as it is: it must not toggle the value back on.
+ assert_eq!(hidden(&h), [true, false, false], "Apple was unchecked");
+ assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu");
+ assert_eq!(active(&h), "A1", "Enter must not move the active cell");
+}
+
+#[test]
+fn enter_with_nothing_checked_keeps_the_menu_open() {
+ let mut h = filter_menu_open();
+ // A search that matches nothing leaves no value to show, as Excel's OK is then disabled.
+ h.input_mut().events.push(Event::Text("Bananna".into()));
+ h.run_steps(2);
+ key(&mut h, Key::Enter, Modifiers::NONE);
+ assert_eq!(hidden(&h), [false, false, false], "an empty selection must not hide every row");
+ assert!(h.state().grid.filter_menu.is_some(), "the menu stays open");
+ assert_eq!(active(&h), "A1");
+}
From e91e7f7a68c46e61e222291f37e4eefb2781ec30 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Thu, 15 Oct 2026 19:37:36 -0400
Subject: [PATCH 04/46] ci: delegate FreeBSD workflow, add nix check, and
remove flatpak-pages
---
.github/workflows/flatpak-pages.yml | 178 ----------------------------
.github/workflows/freebsd.yml | 94 ++-------------
.github/workflows/nix.yml | 33 ++++++
3 files changed, 46 insertions(+), 259 deletions(-)
delete mode 100644 .github/workflows/flatpak-pages.yml
create mode 100644 .github/workflows/nix.yml
diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml
deleted file mode 100644
index d08ac430a..000000000
--- a/.github/workflows/flatpak-pages.yml
+++ /dev/null
@@ -1,178 +0,0 @@
-name: Publish Flatpak repository to Pages
-
-on:
- release:
- types: [published]
- workflow_dispatch:
- inputs:
- release_tag:
- description: "Published GitHub release tag to publish (for example, v1.2.3)"
- required: true
- type: string
-
-permissions:
- contents: read
-
-concurrency:
- group: flatpak-pages-${{ github.repository }}
- cancel-in-progress: false
-
-jobs:
- build:
- timeout-minutes: 60
- name: Build Flatpak repository
- # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
- # here, and the validation step below refuses a manual tag with a pre-release suffix.
- if: github.event_name != 'release' || !github.event.release.prerelease
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- # configure-pages only reads the Pages configuration (base_url); deploy needs write.
- pages: read
- env:
- GH_TOKEN: ${{ github.token }}
- RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
- APP_ID: ai.storyteller.gridcraft
- steps:
- - name: Configure Pages
- id: pages
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
-
- - name: Install Flatpak repository tools
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends flatpak ostree
-
- - name: Download and validate release bundles
- shell: bash
- run: |
- set -euo pipefail
- tag="$RELEASE_TAG"
- if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
- echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
- exit 1
- fi
- # A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
- case "${tag%%+*}" in
- *-*)
- echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
- exit 1
- ;;
- esac
- published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
- 'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
- if [[ "$published_tag" != "$tag" ]]; then
- echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
- exit 1
- fi
-
- version="${tag#v}"
- mkdir -p bundles
- gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
- --pattern SHA256SUMS.txt --dir bundles
- for arch in x86_64 aarch64; do
- asset="gridcraft-$version-linux-$arch.flatpak"
- gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
- --pattern "$asset" --dir bundles
- test -s "bundles/$asset"
- (
- cd bundles
- awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
- SHA256SUMS.txt | sha256sum --check -
- )
- done
- test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
-
- for arch in x86_64 aarch64; do
- asset="bundles/gridcraft-$version-linux-$arch.flatpak"
- validation_repo="$(mktemp -d)"
- ostree --repo="$validation_repo" init --mode=archive-z2
- flatpak build-import-bundle "$validation_repo" "$asset"
- expected_ref="app/$APP_ID/$arch/stable"
- ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
- echo "::error::$(basename "$asset") does not contain $expected_ref"
- exit 1
- }
- rm -rf "$validation_repo"
- done
-
- - name: Build repository and site
- shell: bash
- env:
- PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
- run: |
- set -euo pipefail
- site="$RUNNER_TEMP/site"
- repo_dir="$site/flatpak/repo"
- mkdir -p "$repo_dir"
- ostree --repo="$repo_dir" init --mode=archive-z2
-
- previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
- if curl --fail --silent --show-error --location \
- --output /dev/null "$previous_url/summary"; then
- ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
- ostree --repo="$repo_dir" pull --mirror previous
- ostree --repo="$repo_dir" remote delete previous
- fi
-
- version="${RELEASE_TAG#v}"
- for arch in x86_64 aarch64; do
- flatpak build-import-bundle "$repo_dir" \
- "bundles/gridcraft-$version-linux-$arch.flatpak"
- done
- # Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
- # (re-mirrored and re-deployed on every release) does not grow without bound.
- flatpak build-update-repo \
- --prune --prune-depth=3 \
- --title="GridCraft" \
- --comment='Official GridCraft releases' \
- --description='Official Flatpak repository for GridCraft' \
- --homepage="https://github.com/$GITHUB_REPOSITORY" \
- --default-branch=stable \
- "$repo_dir"
-
- for arch in x86_64 aarch64; do
- ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
- done
- test -s "$repo_dir/summary"
-
- base_url="${PAGES_BASE_URL%/}"
- repo_url="$base_url/flatpak/repo/"
- cat >"$site/flatpak/gridcraft.flatpakrepo" <"$site/flatpak/index.html" <GridCraft Flatpak repositoryGridCraft Flatpak repository
Latest published release: $RELEASE_TAG
Add the GridCraft repository
flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
- flatpak install gridcraft $APP_ID
- HTML
- cat >"$site/index.html" <GridCraft downloadsGridCraft downloads
Flatpak repository
Release assets
- HTML
- touch "$site/.nojekyll"
-
- - name: Upload Pages artifact
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
- with:
- path: ${{ runner.temp }}/site
- include-hidden-files: true
-
- deploy:
- timeout-minutes: 60
- name: Deploy Pages
- needs: build
- runs-on: ubuntu-24.04
- permissions:
- pages: write
- id-token: write
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 0a82a910f..6cdde6e47 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -1,95 +1,27 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
-# Test builds run only from the `release` branch (a manual run from any other branch is skipped).
-# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run
-# from a branch builds the tarball too; they need no secrets.
-#
-# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the
-# release binaries and uploads `gridcraft--freebsd-x86_64.tar.gz` as the
-# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the
-# draft GitHub Release.
name: FreeBSD
on:
+ push:
+ branches: [release]
+ pull_request:
+ paths:
+ - .github/workflows/freebsd.yml
+ - packaging/freebsd/**
+ - packaging/env.sh
workflow_dispatch:
- inputs:
- package:
- description: "Build the release tarball instead of running the tests"
- type: boolean
- default: false
- workflow_call:
- inputs:
- version:
- description: "Version for the artifact name (empty: Cargo.toml)"
- type: string
- default: ""
- version_override:
- description: "Also write the version into Cargo.toml before building (test runs)"
- type: boolean
- default: false
- package:
- type: boolean
- default: false
concurrency:
- # Called from release.yml, github.workflow is the caller's name; keep the groups apart.
- group: freebsd-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: ${{ github.workflow == 'FreeBSD' }}
+ group: freebsd-${{ github.ref }}
+ cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
- name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }})
- if: github.ref == 'refs/heads/release' || inputs.package
- runs-on: ubuntu-latest
- timeout-minutes: 300
- env:
- GRIDCRAFT_VERSION: ${{ inputs.version }}
- GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }}
- GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
- GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - name: Check out craft-fonts
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695
- path: craft-fonts
- - name: Build in a FreeBSD VM
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
- with:
- release: "14.3"
- usesh: true
- cpu: 4
- mem: 12288
- envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA"
- # Only the packaging run brings files back (dist/release). The target dir lives outside
- # the synced checkout, because copying it out of the VM fails on large builds.
- copyback: ${{ inputs.package }}
- prepare: |
- pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
- curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
- run: |
- . "$HOME/.cargo/env"
- # The checkout is synced in under another owner; let tests that call git read it.
- git config --global --add safe.directory "*"
- export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0
- rustc --version
- if [ -n "$GRIDCRAFT_PACKAGE" ]; then
- if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then
- cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- fi
- CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh
- else
- cargo build --workspace --all-targets --locked
- CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
- fi
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- if: inputs.package
- with:
- name: release-freebsd
- path: dist/release/*
- if-no-files-found: error
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
+ with:
+ craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
new file mode 100644
index 000000000..18392587a
--- /dev/null
+++ b/.github/workflows/nix.yml
@@ -0,0 +1,33 @@
+# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
+# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
+name: Nix
+
+on:
+ push:
+ branches: [main]
+ paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
+ pull_request:
+ paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nix-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ eval:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
+ - name: nix flake check --no-build
+ run: |
+ if [ -f flake.nix ]; then
+ nix flake check --no-build --all-systems
+ else
+ echo "No flake.nix present in this repository; skipping check."
+ fi
From 27932a2ae4e462d1597fda5395ec18af638aab00 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Fri, 16 Oct 2026 16:26:31 +0000
Subject: [PATCH 05/46] Close control connections on the first line that is not
a request
A line that is not a JSON object with a string method, including an HTTP
request line, bytes that are not UTF-8 or a line over 64 MiB, gets one
error reply and the connection closes before anything after it is read.
Request lines are read with a bound instead of buffering them whole.
---
apps/gridcraft/src/control_server.rs | 216 ++++++++++++++++++++++-----
docs/control-protocol.md | 4 +
2 files changed, 186 insertions(+), 34 deletions(-)
diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs
index e9e05a2e6..4c57ccf41 100644
--- a/apps/gridcraft/src/control_server.rs
+++ b/apps/gridcraft/src/control_server.rs
@@ -1,7 +1,11 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
+//!
+//! The server only reads requests: a line that is not a JSON object with a string `method` (an
+//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
+//! closed, so nothing sent after it runs.
-use std::io::{BufRead, BufReader, Write};
+use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
@@ -13,6 +17,13 @@ const MAX_LINE: usize = 64 * 1024 * 1024;
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
+ let rx = serve_listener(listener, ctx)?;
+ log::info!("control channel listening on 127.0.0.1:{port}");
+ Ok(rx)
+}
+
+/// Accept connections on `listener`; requests arrive on the returned receiver.
+fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result> {
let (tx, rx) = channel();
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
@@ -21,47 +32,184 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result, max: usize) -> Result {
+ buf.clear();
+ // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
+ let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
+ let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
+ if n == 0 {
+ return Ok(false);
+ }
+ if buf.last() == Some(&b'\n') {
+ buf.pop();
+ return Ok(true);
+ }
+ if buf.len() > max {
+ return Err(format!("request too large (over {max} bytes); closing the connection"));
+ }
+ // Last line without a newline, then end of stream.
+ Ok(true)
+}
+
+/// One request line, parsed.
+enum Line {
+ Request {
+ id: Json,
+ method: String,
+ params: Json,
+ },
+ Blank,
+ /// Not a request: reply with this error and close the connection.
+ Reject(String),
+}
+
+fn parse_line(bytes: &[u8]) -> Line {
+ let Ok(text) = std::str::from_utf8(bytes) else {
+ return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
+ };
+ let text = text.trim();
+ if text.is_empty() {
+ return Line::Blank;
+ }
+ if is_http_request_line(text) {
+ return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
+ }
+ let msg = match serde_json::from_str::(text) {
+ Ok(v) => v,
+ Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")),
+ };
+ let Some(obj) = msg.as_object() else {
+ return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
+ };
+ let Some(method) = obj.get("method").and_then(Json::as_str) else {
+ return Line::Reject("not a control request (missing string `method`); closing the connection".into());
+ };
+ Line::Request {
+ id: obj.get("id").cloned().unwrap_or(Json::Null),
+ method: method.to_string(),
+ params: obj.get("params").cloned().unwrap_or(json!({})),
+ }
+}
+
+/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
+fn is_http_request_line(text: &str) -> bool {
+ const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
+ text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
+ || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
+}
+
fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
- let reader = BufReader::new(read);
- for line in reader.lines() {
- let Ok(line) = line else { break };
- if line.trim().is_empty() {
- continue;
- }
- if line.len() > MAX_LINE {
- let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"}));
- continue;
- }
- let msg: Json = match serde_json::from_str(&line) {
- Ok(v) => v,
- Err(e) => {
- let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
- continue;
- }
+ let mut reader = BufReader::new(read);
+ let mut buf = Vec::new();
+ loop {
+ let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
+ Ok(false) => break,
+ // The peer is gone before sending a line: nobody to tell.
+ Err(_) if buf.is_empty() => break,
+ Err(e) => e,
+ Ok(true) => match parse_line(&buf) {
+ Line::Blank => continue,
+ Line::Reject(e) => e,
+ Line::Request { id, method, params } => {
+ let (req, rx) = ControlRequest::new(method, params);
+ if tx.send(req).is_err() {
+ break;
+ }
+ // Wake the UI thread: egui only runs frames when something happens.
+ ctx.request_repaint();
+ let mut reply = rx
+ .recv_timeout(Duration::from_secs(60))
+ .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
+ if let Some(o) = reply.as_object_mut() {
+ o.insert("id".into(), id);
+ }
+ if writeln!(out, "{reply}").is_err() {
+ break;
+ }
+ continue;
+ }
+ },
};
- let id = msg.get("id").cloned().unwrap_or(Json::Null);
- let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string();
- let params = msg.get("params").cloned().unwrap_or(json!({}));
- let (req, rx) = ControlRequest::new(method, params);
- if tx.send(req).is_err() {
- break;
+ // The connection closes either way; a peer that can't read the reason loses nothing.
+ let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
+ break;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Send `input` on a new connection and read reply lines until the server closes it.
+ fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec {
+ let mut c = TcpStream::connect(addr).unwrap();
+ c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+ c.write_all(input).unwrap();
+ // A read timeout ends the list too: the server kept the connection open.
+ BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
+ }
+
+ fn serve_on_loopback() -> (std::net::SocketAddr, Receiver) {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let addr = listener.local_addr().unwrap();
+ (addr, serve_listener(listener, egui::Context::default()).unwrap())
+ }
+
+ /// A browser can POST to the port: its request line closes the connection before the JSON
+ /// line in its body is read, so nothing is dispatched.
+ #[test]
+ fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
+ let (addr, rx) = serve_on_loopback();
+ let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\
+ {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n";
+ let replies = exchange(addr, http);
+ assert_eq!(replies.len(), 1, "{replies:?}");
+ assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
+ assert!(rx.try_recv().is_err(), "the body was dispatched");
+ }
+
+ #[test]
+ fn a_line_that_is_not_a_request_closes_the_connection() {
+ let (addr, rx) = serve_on_loopback();
+ std::thread::spawn(move || {
+ for req in rx {
+ let _ = req.reply.send(json!({"ok": true}));
+ }
+ });
+ let mut c = TcpStream::connect(addr).unwrap();
+ c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+ c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
+ let mut r = BufReader::new(c);
+ for id in [1, 2] {
+ let mut line = String::new();
+ r.read_line(&mut line).unwrap();
+ assert_eq!(serde_json::from_str::(&line).unwrap()["id"], id, "{line}");
}
- // Wake the UI thread: egui only runs frames when something happens.
- ctx.request_repaint();
- let mut reply = rx
- .recv_timeout(Duration::from_secs(60))
- .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
- if let Some(o) = reply.as_object_mut() {
- o.insert("id".into(), id);
+ for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
+ let mut input = junk.to_vec();
+ input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n");
+ let replies = exchange(addr, &input);
+ assert_eq!(replies.len(), 1, "{replies:?}");
+ assert_eq!(replies[0]["ok"], false);
}
- if writeln!(out, "{reply}").is_err() {
- break;
- }
+ assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
+ assert!(is_http_request_line("PRI * HTTP/2.0"));
+ assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
+ }
+
+ #[test]
+ fn an_overlong_line_is_refused_without_reading_the_rest() {
+ let mut buf = Vec::new();
+ let long = [b'x'; 100];
+ let mut input: &[u8] = &long;
+ assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
+ assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
}
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 7cc6ec230..e82ae7299 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -15,6 +15,10 @@ Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets on
Errors come back as `{"id": …, "ok": false, "error": "…"}`.
+A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON,
+bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the
+connection; nothing sent after it runs. Blank lines are skipped.
+
## Methods
| Method | Params | What it does |
From 823c56582d00be91e31b4f7f3680496cf89e84fb Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Sat, 17 Oct 2026 20:03:36 -0400
Subject: [PATCH 06/46] Release 0.8.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 32 ++++++++++++++++----------------
Cargo.toml | 2 +-
2 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 6d618a02a..17ebf848c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "gridcraft"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"arboard",
"craft-print",
@@ -1518,7 +1518,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1530,7 +1530,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1553,7 +1553,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"js-sys",
"serde",
@@ -1563,7 +1563,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1584,7 +1584,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -1593,7 +1593,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -1601,7 +1601,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -1610,7 +1610,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1622,18 +1622,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
-version = "0.7.0"
+version = "0.8.0"
[[package]]
name = "gridcraft-ui-egui"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
@@ -1655,7 +1655,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -1672,7 +1672,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"aes",
"gridcraft-core",
@@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.7.0"
+version = "0.8.0"
dependencies = [
"gridcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 98dd572e0..a967b999f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
-version = "0.7.0"
+version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
From 217a8907ffe9552c88329cdb693553048c003774 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sun, 18 Oct 2026 02:34:28 +0200
Subject: [PATCH 07/46] Reject unknown value-field func in insert.pivotTable
(#1757)
Found while testing crates/engine/src/cmd/pivot.rs:678 (func_param at :248)
Co-authored-by: Claude Haiku 5.5
---
crates/engine/src/cmd/pivot.rs | 8 +++++-
crates/engine/tests/pivot_unknown_func.rs | 33 +++++++++++++++++++++++
2 files changed, 40 insertions(+), 1 deletion(-)
create mode 100644 crates/engine/tests/pivot_unknown_func.rs
diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs
index 8a441fa1b..06050f7e8 100644
--- a/crates/engine/src/cmd/pivot.rs
+++ b/crates/engine/src/cmd/pivot.rs
@@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result {
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
Json::Object(_) => {
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
- add_to_area("insert.pivotTable", pt, &src, n, area, func_param(str_param(f, "func")), str_param(f, "name"), None)?;
+ let func = match str_param(f, "func") {
+ Some(fname) => {
+ Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?)
+ }
+ None => None,
+ };
+ add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
let by = group_param(str_param(f, "group"));
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
&& matches!(area, "rows" | "columns")
diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/pivot_unknown_func.rs
new file mode 100644
index 000000000..f4d2427f0
--- /dev/null
+++ b/crates/engine/tests/pivot_unknown_func.rs
@@ -0,0 +1,33 @@
+use gridcraft_engine::Session;
+use serde_json::json;
+
+fn session() -> Session {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("range.setValues", json!({"range":"A1", "values":[["Region", "Sales"], ["East", "10"], ["East", "2"], ["West", "4"]]})).unwrap();
+ s
+}
+
+#[test]
+fn insert_pivot_table_rejects_unknown_func() {
+ for func in ["stdev", "sumx"] {
+ let mut s = session();
+ let r = s.execute(
+ "insert.pivotTable",
+ json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":func}]}),
+ );
+ assert!(r.is_err(), "unknown func `{func}` accepted silently");
+ assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty());
+ }
+}
+
+#[test]
+fn insert_pivot_table_accepts_known_func() {
+ let mut s = session();
+ s.execute(
+ "insert.pivotTable",
+ json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":"stddev"}]}),
+ )
+ .unwrap();
+ assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 1);
+}
From 83189c0ccd9138539f576fbaf39d38712e05f5ad Mon Sep 17 00:00:00 2001
From: Dante Bertuzzi <67659136+dantebertuzzi@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:35:20 -0300
Subject: [PATCH 08/46] Fonts: probe ChromeOS's /usr/share/fonts/chromeos for
script fallbacks (fixes #1762) (#1768)
Crostini's cros-host-fonts mounts the host's fonts under
/usr/share/fonts/chromeos (noto/, notocjk/, crosextra/), which none of the
fixed Linux path lists reached: Thai, Hebrew, CJK and Symbols 2 fell back to
egui's Latin-only faces and drew tofu with the fonts installed, and the UI and
cell roles missed Noto Sans and Carlito. Add those folders to each list, with a
structural test that every list probes them.
Co-authored-by: Claude Opus 5.5
---
crates/ui-egui/src/theme.rs | 43 +++++++++++++++++++++++++++++++------
1 file changed, 37 insertions(+), 6 deletions(-)
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 485b9a2f3..08097ba27 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -252,7 +252,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
- let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"];
+ // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762).
+ let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"];
let mut v: Vec<(String, u32)> = Vec::new();
let mut add = |s: String, i: u32| v.push((s, i));
match role {
@@ -498,10 +499,10 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
}
/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`,
-/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and
-/// flat layouts.
+/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux
+/// container (`chromeos/notocjk`, #1762), plus local and flat layouts.
#[cfg(not(target_arch = "wasm32"))]
-const LINUX_CJK_DIRS: [&str; 10] = [
+const LINUX_CJK_DIRS: [&str; 11] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/google-noto-cjk",
"/usr/share/fonts/noto-cjk",
@@ -512,6 +513,7 @@ const LINUX_CJK_DIRS: [&str; 10] = [
"/usr/share/fonts",
"/usr/local/share/fonts",
"/usr/local/share/fonts/noto-cjk",
+ "/usr/share/fonts/chromeos/notocjk",
];
/// Japanese Han + Kana faces (Japanese kanji glyph shapes).
@@ -581,7 +583,13 @@ fn thai() -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
- let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
+ let lin = [
+ "/usr/share/fonts/truetype/noto",
+ "/usr/share/fonts/opentype/noto",
+ "/usr/share/fonts/truetype",
+ "/usr/share/fonts",
+ "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
+ ];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
@@ -603,7 +611,13 @@ fn hebrew() -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
- let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
+ let lin = [
+ "/usr/share/fonts/truetype/noto",
+ "/usr/share/fonts/opentype/noto",
+ "/usr/share/fonts/truetype",
+ "/usr/share/fonts",
+ "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
+ ];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\david.ttf"), 0)); // David
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
@@ -657,6 +671,7 @@ fn symbols_more() -> Vec<(String, u32)> {
"/usr/share/fonts/google-noto-sans-symbols-2-fonts",
"/usr/share/fonts/google-noto",
"/usr/local/share/fonts/noto",
+ "/usr/share/fonts/chromeos/noto",
] {
v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0));
}
@@ -888,6 +903,22 @@ mod tests {
}
}
+ #[test]
+ fn chromeos_font_layout_is_probed() {
+ // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per
+ // family set; a script whose list misses it renders as tofu with the font installed.
+ let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path);
+ let cros = "/usr/share/fonts/chromeos";
+ assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf")));
+ assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf")));
+ assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
+ assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
+ assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
+ assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf")));
+ assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf")));
+ assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf")));
+ }
+
#[test]
fn unparseable_font_files_are_rejected_not_fatal() {
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
From 19f4387cca2ff373da2cfcb851b5cb9cead9e14b Mon Sep 17 00:00:00 2001
From: Dante Bertuzzi <67659136+dantebertuzzi@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:37:09 -0300
Subject: [PATCH 09/46] Conditional formatting: show a rule's number format
(fixes #1163) (#1769)
The grid's look() and print's cell_look() layered a rule's fill, font and
borders onto the cell style but not its num_fmt, so a cell under a rule with a
number format kept its own. Take the rule's code when it isn't General (in a
differential style General means "not set", as layer_under treats it).
Print also re-formatted every non-General cell from its own style through
display::cell_text; format with the look's code instead, through a new
display::value_text_as that value_text now calls (CSV/Text output unchanged).
Show Formulas keeps cell_text.
Co-authored-by: Claude Opus 5.5
---
crates/engine/src/cmd/print.rs | 22 +++++++++++++++++++++-
crates/engine/src/display.rs | 8 +++++++-
crates/ui-egui/src/grid.rs | 26 ++++++++++++++++++++++++++
3 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs
index a10f19d46..8350e56d7 100644
--- a/crates/engine/src/cmd/print.rs
+++ b/crates/engine/src/cmd/print.rs
@@ -650,6 +650,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option String {
/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result.
pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let style = wb.styles.get(sheet.style_id(c));
- let f = format(&sheet.value(c), style.num_fmt.as_str(), wb);
+ value_text_as(wb, &sheet.value(c), style.num_fmt.as_str())
+}
+
+/// [`value_text`] for a value and a format code given apart from a cell (print takes the code
+/// from the cell's effective look, which a conditional format can override).
+pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String {
+ let f = format(v, code, wb);
match f.fill {
Some((_, pos)) => {
let mut t = f.text;
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 00e0c18be..90a39cb06 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -482,6 +482,10 @@ fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (
if st.font.strike {
style.font.strike = true;
}
+ // In a differential style General means "not set" (#1163).
+ if st.num_fmt.as_str() != "General" {
+ style.num_fmt = st.num_fmt.clone();
+ }
for (dst, src) in [
(&mut style.borders.left, st.borders.left),
(&mut style.borders.right, st.borders.right),
@@ -3406,6 +3410,28 @@ mod tests {
assert_eq!(center_across_end(0, 1, |_| true), 1);
}
+ #[test]
+ fn conditional_number_format_reaches_the_cell_text() {
+ // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs.
+ let mut session = gridcraft_engine::Session::new();
+ session.new_workbook();
+ session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap();
+ session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap();
+ let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}});
+ session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
+ let wb = session.active().unwrap().wb.clone();
+ let sh = wb.sheet(0).unwrap();
+ let mut cf = CfCache::new();
+ let text = |row: u32, cf: &mut CfCache| {
+ let c = CellRef::new(row, 0);
+ let (lk, _) = look(&wb, 0, sh, c, cf);
+ display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0
+ };
+ assert_eq!(text(0, &mut cf), "1");
+ assert_eq!(text(1, &mut cf), "3.00");
+ assert_eq!(text(2, &mut cf), "4.00");
+ }
+
#[test]
fn filter_menu_uses_the_table_whose_header_was_clicked() {
use gridcraft_engine::model::Table;
From c5a53a90ac2908408625838a8dc24469ab30f98c Mon Sep 17 00:00:00 2001
From: Higgs32584
Date: Sat, 17 Oct 2026 21:41:43 -0400
Subject: [PATCH 10/46] #1447: booleans and errors count as strings in pivot
cache flags
Excel counts TRUE/FALSE and error values as strings in a cache field's
. GridCraft wrote containsString="0" (and counted booleans and
errors as separate kinds for containsMixedTypes) for such columns, which made
the file unopenable in Excel, even when the column wasn't used in the pivot.
Add Stats::strings() (text, booleans, errors) and use it for
containsSemiMixedTypes, containsString and containsMixedTypes.
Co-Authored-By: Claude Opus 5.5
---
crates/xlsx/src/pivot.rs | 12 ++++--
crates/xlsx/src/tests/pivot_tests.rs | 61 ++++++++++++++++++++++++++++
2 files changed, 69 insertions(+), 4 deletions(-)
diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs
index 609ae530e..920d12ae7 100644
--- a/crates/xlsx/src/pivot.rs
+++ b/crates/xlsx/src/pivot.rs
@@ -208,8 +208,13 @@ impl Stats {
Value::Array(_) => self.add(&v.scalar()),
}
}
+ /// Excel counts booleans and errors as strings in the cache field flags.
+ fn strings(&self) -> bool {
+ self.text || self.boolean || self.error
+ }
+ /// Kinds for `containsMixedTypes`: strings (text, booleans, errors) and numbers.
fn kinds(&self) -> usize {
- [self.text, self.number, self.boolean, self.error].iter().filter(|b| **b).count()
+ usize::from(self.strings()) + usize::from(self.number)
}
}
@@ -709,11 +714,10 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String {
}
let _ = g;
} else {
- let semi = st.text || st.blank || st.boolean || st.error;
- if !semi {
+ if !st.strings() && !st.blank {
s.push_str(" containsSemiMixedTypes=\"0\"");
}
- if !st.text {
+ if !st.strings() {
s.push_str(" containsString=\"0\"");
}
if st.blank {
diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs
index 99a15d6e7..bb0adacc5 100644
--- a/crates/xlsx/src/tests/pivot_tests.rs
+++ b/crates/xlsx/src/tests/pivot_tests.rs
@@ -402,6 +402,67 @@ fn large_cache_is_written_without_records() {
assert_eq!(back.sheet(0).unwrap().pivots.len(), 1);
}
+/// #1447 item 1: Excel counts booleans and errors as strings in ``; writing
+/// `containsString="0"` for them makes a file Excel can't open.
+#[test]
+fn boolean_and_error_cache_flags_match_excel() {
+ let mut wb = Workbook::new();
+ let s = wb.sheet_mut(0).unwrap();
+ let na = || Value::Error(gridcraft_core::CellError::NA);
+ let cols: [(&str, [Value; 3]); 7] = [
+ ("K", [Value::text("a"), Value::text("b"), Value::text("a")]),
+ ("BoolOnly", [Value::Bool(true), Value::Bool(false), Value::Bool(true)]),
+ ("NumBool", [Value::number(1.0), Value::Bool(true), Value::number(2.5)]),
+ ("ErrOnly", [na(), na(), na()]),
+ ("NumErr", [Value::number(1.0), na(), Value::number(3.0)]),
+ ("TextBool", [Value::text("x"), Value::Bool(true), Value::text("y")]),
+ ("BoolBlank", [Value::Bool(true), Value::Empty, Value::Bool(false)]),
+ ];
+ for (c, (h, vals)) in cols.iter().enumerate() {
+ s.cells.set(CellRef::new(0, c as u32), Cell::value(Value::text(*h)));
+ for (r, v) in vals.iter().enumerate() {
+ if !matches!(v, Value::Empty) {
+ s.cells.set(CellRef::new(r as u32 + 1, c as u32), Cell::value(v.clone()));
+ }
+ }
+ }
+ s.pivots.push(PivotTable {
+ id: 1,
+ source: "Sheet1!$A$1:$G$4".into(),
+ anchor: at("J1"),
+ rows: vec![field("K")],
+ values: vec![PivotValue { source_col: "NumBool".into(), name: "Sum of NumBool".into(), ..PivotValue::default() }],
+ last_range: Some(rr("J1:K4")),
+ ..PivotTable::default()
+ });
+ let bytes = write_xlsx(&wb).unwrap();
+ let parts = unzip(&bytes);
+ let c = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition1.xml").unwrap().clone()).unwrap();
+ let start = |name: &str| -> String {
+ let i = c.find(&format!("').unwrap();
+ c[j..=k].trim_end_matches('>').trim_end_matches('/').to_string()
+ };
+ let flags = |name: &str| -> String {
+ let t = start(name);
+ // Drop the trailing count attribute, which depends on itemization.
+ match t.find(" count=") {
+ Some(i) => t[..i].to_string(),
+ None => t,
+ }
+ };
+ assert_eq!(flags("BoolOnly"), ""#;
const PT_CACHE_RELS: &str = r#""#;
From c30fa3f1cf324603f3bfc0171689b1d4083b40e5 Mon Sep 17 00:00:00 2001
From: Mex <44814932+MMexicann@users.noreply.github.com>
Date: Sun, 18 Oct 2026 05:13:03 +0200
Subject: [PATCH 11/46] fix(functions): avoid overflow in median midpoints
(#1579) (#1585)
---
crates/calc/src/tests.rs | 28 ++++++++++++++++++++++++
crates/functions/src/stat.rs | 42 +++++++++++++++++++++++++++++++++++-
docs/function-parity.md | 8 ++++++-
3 files changed, 76 insertions(+), 2 deletions(-)
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 196c57de7..97f7a8350 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -1266,3 +1266,31 @@ fn offset_extreme_arguments_return_ref() {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}
+
+#[test]
+fn median_large_values_recalculate_dependents() {
+ let mut t = T::new();
+ t.set("A1", "1e308");
+ t.set("A2", "1.6e308");
+ t.set("B1", "=MEDIAN(A1:A2)");
+ t.set("B2", "=AGGREGATE(12,0,A1:A2)");
+ t.set("C1", "=B1/1e308");
+ t.set("C2", "=B2/1e308");
+ for cell in ["B1", "B2"] {
+ assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14);
+ }
+ for cell in ["C1", "C2"] {
+ assert!((t.num(cell) - 1.3).abs() < 1e-14);
+ }
+ t.set("A2", "-1e308");
+ for cell in ["B1", "B2", "C1", "C2"] {
+ assert_eq!(t.num(cell), 0.0);
+ }
+ t.set("A1", "-1.6e308");
+ for cell in ["B1", "B2"] {
+ assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14);
+ }
+ for cell in ["C1", "C2"] {
+ assert!((t.num(cell) + 1.3).abs() < 1e-14);
+ }
+}
diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs
index 7c50abeaa..9d8642e24 100644
--- a/crates/functions/src/stat.rs
+++ b/crates/functions/src/stat.rs
@@ -54,7 +54,7 @@ fn median(v: Vec) -> R {
let s = sorted(v);
let n = s.len();
let hi = s.get(n / 2).copied().unwrap_or(0.0);
- if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) }
+ if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }
}
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
@@ -2255,6 +2255,46 @@ mod tests {
av(col(xs))
}
+ // The two middle values are finite and their midpoint is representable even when
+ // adding them first would overflow. Exercise both callers of the shared helper.
+ #[test]
+ fn median_finite_midpoint_does_not_overflow() {
+ for (values, expected) in [
+ (vec![1e308, 1e308], 1e308),
+ (vec![-1e308, -1e308], -1e308),
+ (vec![1.6e308, 1e308], 1.3e308),
+ (vec![-1e308, -1.6e308], -1.3e308),
+ (vec![f64::MAX, f64::MAX], f64::MAX),
+ (vec![-f64::MAX, -f64::MAX], -f64::MAX),
+ (vec![-f64::MAX, f64::MAX], 0.0),
+ (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308),
+ ] {
+ close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected);
+ close(ev("MEDIAN", vec![rf(col(&values))]), expected);
+ close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected);
+ }
+ }
+
+ #[test]
+ fn median_midpoint_keeps_small_values_and_argument_semantics() {
+ // Halving each operand unconditionally can lose the smallest numbers. Use exact
+ // equality here: the usual aggregate-test tolerance would accept zero.
+ for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] {
+ assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value));
+ assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value));
+ }
+ close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5);
+ close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0);
+ close(ev("MEDIAN", vec![n(1e308)]), 1e308);
+ close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5);
+ close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5);
+ is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value);
+ is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA);
+ is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num);
+ is_err(aggregate_values(12, &[]), CellError::Num);
+ is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0);
+ }
+
#[test]
fn averages_and_counts() {
close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0);
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 274800699..2343e0de9 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -101,6 +101,11 @@ fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero-
denominators, `?` placeholders padding the thousands separator, and booleans through the text
section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`).
+`MEDIAN` and `AGGREGATE` function number 12 use an overflow-safe midpoint for even-sized
+inputs, so large finite middle values do not become `#NUM!` merely because their sum overflows (#1579).
+Sorting, argument coercion and error propagation are unchanged; this is a numerical correction
+under the documented median rule, not an extreme-value comparison against desktop Excel.
+
Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above),
an oracle corpus of Excel-computed results per function (owner runs Excel on generated
workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h).
@@ -109,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation |
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
From 6082355c224a83ac429d922fe7556f87c1b6d10a Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:49:17 +0200
Subject: [PATCH 12/46] Translate the cell and header context menus into German
(#1830)
---
crates/ui-egui/src/i18n.rs | 35 +++++++++++++++++++++++++++++++++++
crates/ui-egui/src/i18n/de.rs | 12 ++++++++++++
2 files changed, 47 insertions(+)
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 061698df9..064f59806 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -647,6 +647,41 @@ mod tests {
assert_eq!(Language::parse("xx"), None);
}
+ #[test]
+ fn german_cell_and_header_context_menus_are_fully_translated() {
+ for label in [
+ "Cut",
+ "Copy",
+ "Paste",
+ "Paste Special…",
+ "Insert…",
+ "Delete…",
+ "Clear Contents",
+ "Filter by Selected Cell's Value",
+ "Sort A to Z",
+ "Sort Z to A",
+ "New Comment",
+ "New Note",
+ "Format Cells…",
+ "Pick From Drop-down List…",
+ "Define Name…",
+ "Insert Copied Cells",
+ "Insert Copied Cells…",
+ "Insert Cut Cells",
+ "Insert",
+ "Delete",
+ "Row Height…",
+ "AutoFit Row Height",
+ "Column Width…",
+ "AutoFit Column Width",
+ "Hide",
+ "Unhide",
+ "Group",
+ ] {
+ assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}");
+ }
+ }
+
#[test]
fn unknown_saved_language_keeps_the_other_preferences() {
let ui: crate::UiState = serde_json::from_str(r#"{"dark": true, "language": "xx"}"#).unwrap();
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index c20c7d1b3..371ea7981 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -411,6 +411,18 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Dunkel",
"Sheet Options|View" => "Anzeige",
"Print" => "Drucken",
+ // grid.rs cell and row/column header context menus.
+ "Cut" => "Ausschneiden",
+ "Copy" => "Kopieren",
+ "Insert…" => "Einfügen…",
+ "Delete…" => "Löschen…",
+ "Filter by Selected Cell's Value" => "Nach Wert der gewählten Zelle filtern",
+ "New Comment" => "Neuer Kommentar",
+ "Pick From Drop-down List…" => "Aus Dropdownliste auswählen…",
+ "Define Name…" => "Namen definieren…",
+ "Link…" => "Link…",
+ "Hide" => "Ausblenden",
+ "Unhide" => "Einblenden",
_ => return None,
})
}
From beade8b734732c069df6c9446afe162eebd2df56 Mon Sep 17 00:00:00 2001
From: Eduardo Garcia
Date: Sun, 18 Oct 2026 12:32:47 -0300
Subject: [PATCH 13/46] #1528: a formula's references are outlined only on the
sheet they belong to
While pointing at another sheet, a reference without a sheet name (A1) was outlined on the
sheet being shown instead of the sheet the edit started on.
Co-Authored-By: Claude Sonnet 5.5
---
crates/ui-egui/src/editor.rs | 17 +++++++++++++++++
crates/ui-egui/src/grid.rs | 3 ++-
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs
index 4aada2953..799109f89 100644
--- a/crates/ui-egui/src/editor.rs
+++ b/crates/ui-egui/src/editor.rs
@@ -238,6 +238,13 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option,
out
}
+/// Whether a reference of the formula being edited belongs on the sheet shown: a reference with a
+/// sheet name is on that sheet, one without is on the sheet the edit started on, even while
+/// pointing at another sheet.
+pub fn ref_is_on_sheet(ref_sheet: Option<&str>, origin: &str, shown: &str) -> bool {
+ ref_sheet.unwrap_or(origin).eq_ignore_ascii_case(shown)
+}
+
/// Excel-style AutoComplete: the one distinct text entry in the same column (contiguous data
/// above and below) that starts with `typed` (case-insensitive). `None` when ambiguous.
pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, typed: &str) -> Option {
@@ -329,4 +336,14 @@ mod tests {
assert_eq!(r[1].2.a1(), "B2:C3");
assert_eq!(r[3].3.as_deref(), Some("Sheet2"));
}
+
+ #[test]
+ fn refs_are_outlined_on_their_own_sheet_only() {
+ // Pointing at Sheet2 while the edit started on Sheet1: `A1` stays on Sheet1, `Sheet2!B2` shows on Sheet2.
+ assert!(ref_is_on_sheet(None, "Sheet1", "Sheet1"));
+ assert!(!ref_is_on_sheet(None, "Sheet1", "Sheet2"));
+ assert!(ref_is_on_sheet(Some("Sheet2"), "Sheet1", "Sheet2"));
+ assert!(!ref_is_on_sheet(Some("Sheet2"), "Sheet1", "Sheet1"));
+ assert!(ref_is_on_sheet(Some("sheet2"), "Sheet1", "Sheet2"), "sheet names compare ignoring case");
+ }
}
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 90a39cb06..272e81c21 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -1623,8 +1623,9 @@ pub(crate) fn paint_selection(
if let Some(ed) = &app.editor
&& ed.is_formula()
{
+ let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str());
for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) {
- if sheet.as_deref().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
+ if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) {
continue;
}
if !range_in_pane(r, rows, cols) {
From 95f8206bb9e69549869a3ef35191bd2b1e14e099 Mon Sep 17 00:00:00 2001
From: Eduardo Garcia
Date: Sun, 18 Oct 2026 12:49:32 -0300
Subject: [PATCH 14/46] #1808: file.open can keep every CSV/TSV field as text
file.open takes {csvAsText: true}: a CSV, TSV or TXT file opens with every field as the text
it holds, with no number, date or boolean conversion, so identifiers such as GTINs, SKUs and
ZIP codes keep their digits and leading zeros and save back unchanged. The default is
unchanged. The CsvOptions.parse_values switch already existed in the reader.
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/file.rs | 4 ++--
crates/engine/src/io.rs | 17 ++++++++++++-----
crates/engine/src/tests.rs | 26 ++++++++++++++++++++++++++
docs/file-format-parity.md | 3 ++-
4 files changed, 42 insertions(+), 8 deletions(-)
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 7291ee897..9ddc05611 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec {
vec![
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
- cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
+ cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook and {csvAsText?: bool} to keep every csv/tsv/txt field as text, with no number or date conversion (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -56,7 +56,7 @@ fn open(s: &mut Session, p: &Json) -> Result {
return ok();
};
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
- let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
+ let (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index 3e84036cb..4a14b8790 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -55,8 +55,15 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> {
/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never
/// quote the password.
pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec)> {
+ open_bytes_with(name, bytes, password, false)
+}
+
+/// [`open_bytes_with_password`], and with `csv_as_text` a CSV/TSV/TXT file keeps every field as
+/// the text it holds, with no number, date or boolean conversion (identifiers such as GTINs, SKUs
+/// and ZIP codes survive). It does not apply to other formats.
+pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>, csv_as_text: bool) -> Result<(Workbook, Vec)> {
if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted {
- let (mut wb, warnings) = open_plain(name, bytes)?;
+ let (mut wb, warnings) = open_plain(name, bytes, csv_as_text)?;
fit_imported_rows(&mut wb);
return Ok((wb, warnings));
}
@@ -70,7 +77,7 @@ pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>
if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
}
- let (mut wb, mut warnings) = open_plain(name, &plain)?;
+ let (mut wb, mut warnings) = open_plain(name, &plain, csv_as_text)?;
fit_imported_rows(&mut wb);
warnings.insert(0, DECRYPTED_NOTICE.into());
Ok((wb, warnings))
@@ -112,7 +119,7 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
}
/// Parses bytes that aren't encrypted.
-fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> {
+fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Xls {
@@ -153,7 +160,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> {
Ok((wb, vec![]))
}
Some(FileKind::Tsv) => {
- let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() };
+ let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
@@ -161,7 +168,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> {
Ok((wb, vec![]))
}
_ => {
- let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, ..Default::default() };
+ let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ae499a189..c7af79c31 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -3282,3 +3282,29 @@ fn paste_special_validation_moves_relative_references() {
assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1");
assert!(ok(&mut s, "C3", "20"));
}
+
+#[test]
+fn csv_as_text_keeps_identifier_columns_as_written() {
+ let csv = "gtin,zip,flag,day\n8435392892278,01234,TRUE,2026-03-04\n";
+ let open = |s: &mut Session, extra: serde_json::Value| {
+ let mut p = json!({"name": "feed.csv", "base64": crate::io::base64_encode(csv.as_bytes())});
+ if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
+ p.extend(extra.clone());
+ }
+ s.execute("file.open", p).unwrap();
+ };
+ let value = |s: &mut Session, cell: &str| s.execute("cell.get", json!({"cell": cell})).unwrap()["value"].clone();
+ // By default fields are typed like typed input: the leading zero is lost and the GTIN is a number.
+ let mut s = s();
+ open(&mut s, json!({}));
+ assert!(value(&mut s, "A2").is_number());
+ assert_eq!(value(&mut s, "B2").as_f64(), Some(1234.0));
+ // With csvAsText every field stays the text the file holds, and saves back unchanged.
+ let mut s = self::s();
+ open(&mut s, json!({"csvAsText": true}));
+ for (cell, text) in [("A2", "8435392892278"), ("B2", "01234"), ("C2", "TRUE"), ("D2", "2026-03-04")] {
+ assert_eq!(value(&mut s, cell), json!(text), "{cell}");
+ }
+ let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
+ assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes());
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 158149fb3..48af53877 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
From 9b8fde71eea8bc1fd44b4a1dbd1d70a38253598f Mon Sep 17 00:00:00 2001
From: Raleigh Dexel
Date: Sun, 18 Oct 2026 11:04:48 -0700
Subject: [PATCH 15/46] feat(ui): let an embedding host turn off the title bar
and File tab
SheetApp gains a non-persisted `chrome` field. Both parts stay on by
default; a host that owns its window and files can hide the title bar,
and the File tab together with the Backstage view it opens.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/lib.rs | 30 +++++++++++++++-
crates/ui-egui/src/ribbon.rs | 4 ++-
crates/ui-egui/tests/host_chrome.rs | 54 +++++++++++++++++++++++++++++
3 files changed, 86 insertions(+), 2 deletions(-)
create mode 100644 crates/ui-egui/tests/host_chrome.rs
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index f96cdac3c..3c6ed2b02 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -209,9 +209,31 @@ pub struct Perf {
last: f64,
}
+/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host
+/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar
+/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the
+/// host, not a user preference.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Chrome {
+ /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and
+ /// the theme switch.
+ pub title_bar: bool,
+ /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and
+ /// commands such as `ui.backstage`).
+ pub file_tab: bool,
+}
+
+impl Default for Chrome {
+ fn default() -> Self {
+ Chrome { title_bar: true, file_tab: true }
+ }
+}
+
pub struct SheetApp {
pub session: Session,
pub ui: UiState,
+ /// The window chrome to draw (see [`Chrome`]).
+ pub chrome: Chrome,
pub views: HashMap<(u64, usize), SheetView>,
pub editor: Option,
pub text_box_editor: Option,
@@ -270,6 +292,7 @@ impl SheetApp {
SheetApp {
session,
ui: UiState::default(),
+ chrome: Chrome::default(),
views: HashMap::new(),
editor: None,
text_box_editor: None,
@@ -752,6 +775,9 @@ impl SheetApp {
/// Opens the File tab's Backstage view on `page`, committing a cell still being edited first
/// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet.
pub fn open_backstage(&mut self, page: backstage::Page) {
+ if !self.chrome.file_tab {
+ return;
+ }
if !self.commit_edit(0, 0, false, false) {
return;
}
@@ -1106,7 +1132,9 @@ impl SheetApp {
if self.backstage.is_none() {
self.ribbon_keys(&ctx);
}
- ribbon::title_bar(self, ui);
+ if self.chrome.title_bar {
+ ribbon::title_bar(self, ui);
+ }
if self.backstage.is_some() {
// File tab: the Backstage view replaces everything below the title bar.
backstage::show(self, ui);
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
index 9c6d89eeb..9edd1deb9 100644
--- a/crates/ui-egui/src/ribbon.rs
+++ b/crates/ui-egui/src/ribbon.rs
@@ -176,7 +176,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.add_space(8.0);
- file_menu(app, ui);
+ if app.chrome.file_tab {
+ file_menu(app, ui);
+ }
let mut tabs: Vec<&str> = TABS.to_vec();
let ctx_tabs = contextual_tabs(app);
tabs.extend(ctx_tabs.iter());
diff --git a/crates/ui-egui/tests/host_chrome.rs b/crates/ui-egui/tests/host_chrome.rs
new file mode 100644
index 000000000..0773f4c49
--- /dev/null
+++ b/crates/ui-egui/tests/host_chrome.rs
@@ -0,0 +1,54 @@
+//! A host that embeds the sheet in its own window can turn off the app's title bar and File tab.
+
+use egui_kittest::kittest::Queryable as _;
+use gridcraft_engine::Session;
+use gridcraft_ui_egui::{Chrome, Services, SheetApp, backstage::Page};
+
+fn harness(chrome: Chrome) -> egui_kittest::Harness<'static, SheetApp> {
+ let mut session = Session::new();
+ session.new_workbook();
+ let mut app = SheetApp::new(session, Services::default());
+ app.chrome = chrome;
+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+ |ui, app: &mut SheetApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(4);
+ h
+}
+
+#[test]
+fn the_title_bar_and_file_tab_are_drawn_by_default() {
+ let h = harness(Chrome::default());
+ assert!(h.query_by_label("AutoSave").is_some(), "the title bar is drawn");
+ assert!(h.query_by_label("File").is_some(), "the File tab is drawn");
+}
+
+#[test]
+fn a_host_can_leave_out_the_title_bar_and_the_file_tab() {
+ let full = harness(Chrome::default());
+ let h = harness(Chrome { title_bar: false, file_tab: false });
+ assert!(h.query_by_label("AutoSave").is_none(), "no title bar");
+ assert!(h.query_by_label("File").is_none(), "no File tab");
+ // The ribbon tabs are painted text, so measure the move with the first ribbon control.
+ let (with_bar, without) = (full.get_by_label("Paste").rect(), h.get_by_label("Paste").rect());
+ let moved = with_bar.min.y - without.min.y;
+ assert!((25.0..=40.0).contains(&moved), "the ribbon moves up by the title bar's height (28 on macOS, 38 elsewhere), moved {moved}");
+ assert_eq!(with_bar.min.x, without.min.x, "the ribbon's groups keep their place across");
+}
+
+#[test]
+fn without_the_file_tab_the_backstage_view_never_opens() {
+ let mut h = harness(Chrome { title_bar: true, file_tab: false });
+ h.state_mut().open_backstage(Page::Home);
+ h.run_steps(3);
+ assert!(h.state().backstage.is_none(), "opening Backstage directly is ignored");
+ h.state_mut().open_backstage(Page::Print);
+ h.run_steps(3);
+ assert!(h.state().backstage.is_none(), "so is every page, Print and Save As included");
+ assert!(h.query_by_label("AutoSave").is_some(), "the title bar can stay on its own");
+}
From 4f3853d4a95e50b9adf7733f9cd14d16b394fb97 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:25:07 +0200
Subject: [PATCH 16/46] Hide blank and text rows for numeric ordering custom
filters
Found while testing crates/engine/src/cmd/data.rs:550 (custom_ok, used from apply_filter at :623)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/data.rs | 2 ++
crates/engine/tests/custom_filter_blanks.rs | 20 ++++++++++++++++++++
2 files changed, 22 insertions(+)
create mode 100644 crates/engine/tests/custom_filter_blanks.rs
diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs
index 383624b70..09d781f84 100644
--- a/crates/engine/src/cmd/data.rs
+++ b/crates/engine/src/cmd/data.rs
@@ -551,6 +551,8 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
let num = gridcraft_core::parse::parse_number_text(crit);
let cmp = match (v.as_f64(), num) {
(Some(a), Some(b)) => a.partial_cmp(&b),
+ // A numeric ordering comparison only matches numbers; blanks and text never do.
+ (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None,
_ => Some(gridcraft_core::compare_text(text, crit)),
};
let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase());
diff --git a/crates/engine/tests/custom_filter_blanks.rs b/crates/engine/tests/custom_filter_blanks.rs
new file mode 100644
index 000000000..9bfd395d2
--- /dev/null
+++ b/crates/engine/tests/custom_filter_blanks.rs
@@ -0,0 +1,20 @@
+use gridcraft_engine::Session;
+use serde_json::json;
+
+fn visible_rows(op: &str) -> Vec {
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("range.setValues", json!({"range":"A1", "values":[["n"], ["3"], ["10"], [""], ["abc1"], ["20"]]})).unwrap();
+ s.execute("selection.set", json!({"range":"A1:A6"})).unwrap();
+ s.execute("data.filter", json!({})).unwrap();
+ s.execute("data.filterBy", json!({"column":0, "custom":{"op":op, "value":"5"}})).unwrap();
+ let doc = s.doc().unwrap();
+ let sh = doc.wb.active().unwrap();
+ (1..6u32).filter(|r| !sh.rows.get(r).is_some_and(|i| i.hidden)).collect()
+}
+
+#[test]
+fn less_than_numeric_criterion_hides_blank_and_text_rows() {
+ assert_eq!(visible_rows("<"), vec![1]);
+ assert_eq!(visible_rows("<="), vec![1]);
+}
From 6e984bb84dfea70ce5c4def29a9fdeb14ffb122c Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 14:31:36 -0400
Subject: [PATCH 17/46] ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor
---
.github/dependabot.yml | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 .github/dependabot.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..2c4b8cb1a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
From 383bb7ba593a9b337ecebdeee31a9e05f6ede68a Mon Sep 17 00:00:00 2001
From: Neil Cawse
Date: Sun, 18 Oct 2026 19:02:18 +0000
Subject: [PATCH 18/46] fix(xlsx): read and write PivotTable grand totals in
Excel's direction
rowGrandTotals is the Grand Total row under the report and colGrandTotals the
Grand Total column at its right. Both were mapped the other way round; a
GridCraft round trip hid it, but an Excel file with only one grand total opened
with the other one.
---
crates/xlsx/src/pivot.rs | 10 ++++++----
crates/xlsx/src/tests/pivot_tests.rs | 23 +++++++++++++++++++++++
docs/file-format-parity.md | 3 ++-
3 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs
index 609ae530e..fd00d0234 100644
--- a/crates/xlsx/src/pivot.rs
+++ b/crates/xlsx/src/pivot.rs
@@ -816,10 +816,12 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
if !pt.show_headers {
s.push_str(" showHeaders=\"0\"");
}
- if !pt.grand_totals_rows {
+ // `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"),
+ // `colGrandTotals` the grand total column at its right ("On for Rows").
+ if !pt.grand_totals_cols {
s.push_str(" rowGrandTotals=\"0\"");
}
- if !pt.grand_totals_cols {
+ if !pt.grand_totals_rows {
s.push_str(" colGrandTotals=\"0\"");
}
s.push_str(" multipleFieldFilters=\"0\">");
@@ -1262,8 +1264,8 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option "#
+ )
+ };
+ for (attr, rows, cols) in [("rowGrandTotals=\"0\"", true, false), ("colGrandTotals=\"0\"", false, true), ("", true, true)] {
+ let (wb, _) = read_xlsx(&with_pivot(&table(attr), Some(GOOD_CACHE))).unwrap();
+ let pt = &wb.sheet(0).unwrap().pivots[0];
+ assert_eq!((pt.grand_totals_rows, pt.grand_totals_cols), (rows, cols), "{attr}");
+ }
+}
+
#[test]
fn data_on_rows_with_one_value_field_reads_without_warning() {
let table = r#" "#;
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 158149fb3..61df43deb 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
From 66ce6be4743f4227047f76f56815520f824e6d81 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 18:14:33 -0400
Subject: [PATCH 19/46] docs: add SonarCloud and CodeRabbit badges to README
(#1860)
* docs: add SonarCloud and CodeRabbit badges to README
* docs: put SonarCloud and CodeRabbit badges on their own line
---
README.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/README.md b/README.md
index 678495f1d..3837b9176 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,9 @@
+
+
+
From f1015e1e6ba2985ead9877fecb4b4d738583bdbd Mon Sep 17 00:00:00 2001
From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com>
Date: Mon, 19 Oct 2026 01:25:51 +0200
Subject: [PATCH 20/46] i18n: French and Serbian Options headings without the
context prefix (#1891)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fr.rs and sr.rs translated three `context|label` keys with the context
kept ("Options|Général", "Opcije|Opšte", "Options de la feuille|Affichage"
...). `Language::tr` shows a found translation as it is, so File › Options
section headings and the Sheet Options "View" checkboxes showed the prefix.
The values now hold only the label.
Tests: `translations_never_show_the_context_prefix` (no catalog shows a `|`
for any key) and `translated_options_headings_show_no_context_prefix`
(File › Options in French and Serbian); both fail on main.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/i18n.rs | 18 ++++++++++++++++++
crates/ui-egui/src/i18n/fr.rs | 6 +++---
crates/ui-egui/src/i18n/sr.rs | 6 +++---
crates/ui-egui/tests/file_menu.rs | 20 ++++++++++++++++++++
docs/localization-parity.md | 3 ++-
5 files changed, 46 insertions(+), 7 deletions(-)
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 061698df9..cd9c842ef 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -647,6 +647,24 @@ mod tests {
assert_eq!(Language::parse("xx"), None);
}
+ #[test]
+ fn translations_never_show_the_context_prefix() {
+ // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
+ // value shows it on screen (File › Options read "Options|Général" in French).
+ for language in Language::ALL {
+ for (english, _) in JAPANESE {
+ let shown = language.tr(english);
+ assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code());
+ }
+ }
+ assert_eq!(Language::Fr.tr("Options|General"), "Général");
+ assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer");
+ assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage");
+ assert_eq!(Language::Sr.tr("Options|General"), "Opšte");
+ assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj");
+ assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz");
+ }
+
#[test]
fn unknown_saved_language_keeps_the_other_preferences() {
let ui: crate::UiState = serde_json::from_str(r#"{"dark": true, "language": "xx"}"#).unwrap();
diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs
index 5d20ab0ca..82027e847 100644
--- a/crates/ui-egui/src/i18n/fr.rs
+++ b/crates/ui-egui/src/i18n/fr.rs
@@ -305,7 +305,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Lookup &\nReference" => "Recherche et\nréférence",
"Math &\nTrig" => "Maths et\ntrigonométrie",
"More\nFunctions" => "Autres\nfonctions",
- "Sheet Options|View" => "Options de la feuille|Affichage",
+ "Sheet Options|View" => "Affichage",
"Print" => "Impression",
"Click to add data" => "Cliquez pour ajouter des données",
"Add header" => "Ajouter un en-tête",
@@ -402,8 +402,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save the active sheet as comma-separated text." => "Enregistrer la feuille active sous forme de texte séparé par des points-virgules.",
"Web Page (.html)" => "Page web (.html)",
"Save the workbook as a web page." => "Enregistrer le classeur en tant que page web.",
- "Options|General" => "Options|Général",
- "Options|Save" => "Options|Enregistrer",
+ "Options|General" => "Général",
+ "Options|Save" => "Enregistrer",
"Show formula bar" => "Afficher la barre de formule",
"Enable AutoComplete for cell values" => "Activer la saisie semi-automatique des valeurs de cellule",
"Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs
index cbf18d4c4..85bd2da72 100644
--- a/crates/ui-egui/src/i18n/sr.rs
+++ b/crates/ui-egui/src/i18n/sr.rs
@@ -297,7 +297,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Lookup &\nReference" => "Pretraga i\nreference",
"Math &\nTrig" => "Matematika i\ntrigonometrija",
"More\nFunctions" => "Još\nfunkcija",
- "Sheet Options|View" => "Opcije lista|Prikaz",
+ "Sheet Options|View" => "Prikaz",
"Print" => "Štampaj",
"Click to add data" => "Kliknite da dodate podatke",
"Add header" => "Dodaj zaglavlje",
@@ -394,8 +394,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save the active sheet as comma-separated text." => "Sačuvaj aktivni list kao tekst odvojen zapetama.",
"Web Page (.html)" => "Veb stranica (.html)",
"Save the workbook as a web page." => "Sačuvaj radnu svesku kao veb stranicu.",
- "Options|General" => "Opcije|Opšte",
- "Options|Save" => "Opcije|Sačuvaj",
+ "Options|General" => "Opšte",
+ "Options|Save" => "Sačuvaj",
"Show formula bar" => "Prikaži traku formule",
"Enable AutoComplete for cell values" => "Omogući automatsko dovršavanje vrednosti ćelija",
"Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene",
diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs
index 691eef367..d4aa13c29 100644
--- a/crates/ui-egui/tests/file_menu.rs
+++ b/crates/ui-egui/tests/file_menu.rs
@@ -132,6 +132,26 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
assert!(h.state().backstage.is_none());
}
+#[test]
+fn translated_options_headings_show_no_context_prefix() {
+ for (code, headings, prefixed) in [
+ ("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"]),
+ ("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"]),
+ ] {
+ let mut h = harness(Services::default());
+ h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
+ h.state_mut().run("file.options", serde_json::json!({})).unwrap();
+ h.run_steps(3);
+ assert_eq!(page(&h), Some("options"));
+ for heading in headings {
+ assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading");
+ }
+ for label in prefixed {
+ assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
+ }
+ }
+}
+
#[test]
fn export_as_pdf_is_delivered_as_a_download() {
let files = Arc::new(Mutex::new(Vec::<(String, Vec)>::new()));
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..91d37aa26 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (French and Serbian `context|label` translations no longer show the context) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -78,6 +78,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 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 | 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 4ed93c3b2c932652a9250c0c42d8be885d6ace50 Mon Sep 17 00:00:00 2001
From: Shnrqpdr
Date: Mon, 19 Oct 2026 00:14:35 -0300
Subject: [PATCH 21/46] Keytips: letters typed with Alt held continue the
sequence (fixes #1846)
Alt+H, O, A (AutoFit Row Height) and Alt+H, O, I (AutoFit Column Width)
ran nothing when Alt was still held for H, as Excel allows: any key with
Alt down cancelled keytips. KeyTips now tracks the left Alt key, and a
letter typed with it held that continues a known sequence is handled as
a keytip letter. Other Alt combinations still cancel; AltGr (right Alt)
and macOS Option are unchanged (#1864).
---
crates/ui-egui/src/keytips.rs | 21 +++++++++-
crates/ui-egui/tests/keytips.rs | 74 +++++++++++++++++++++++++++++++++
docs/ui-parity.md | 5 ++-
3 files changed, 97 insertions(+), 3 deletions(-)
diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs
index e1163d9bb..eda675e7b 100644
--- a/crates/ui-egui/src/keytips.rs
+++ b/crates/ui-egui/src/keytips.rs
@@ -24,6 +24,8 @@ pub enum Action {
pub struct KeyTips {
prefix: Option,
alt_armed: bool,
+ // Left Alt is down: Alt+H (held, as Excel allows) types keytip letters.
+ alt_held: bool,
// Keep consuming a held mnemonic after its action closes keytips.
held: Vec,
}
@@ -36,6 +38,7 @@ impl KeyTips {
pub fn clear(&mut self) {
self.prefix = None;
self.alt_armed = false;
+ self.alt_held = false;
self.held.clear();
}
@@ -61,6 +64,9 @@ impl KeyTips {
Event::PointerButton { pressed: true, .. } | Event::MouseWheel { .. } | Event::WindowFocused(false) | Event::Ime(_) => self.clear(),
Event::Copy | Event::Cut | Event::Paste(_) => self.clear(),
Event::ModifiersChanged(m) => {
+ if !m.alt {
+ self.alt_held = false;
+ }
if command_modifier(*m) || (mac && m.alt) {
self.clear();
} else if m.shift {
@@ -68,6 +74,7 @@ impl KeyTips {
}
}
Event::Key { key: Key::AltLeft, pressed, repeat, modifiers, .. } if !mac && !command_modifier(*modifiers) && !modifiers.shift => {
+ self.alt_held = *pressed;
if *pressed {
if !repeat {
self.alt_armed = true;
@@ -80,8 +87,20 @@ impl KeyTips {
}
Event::Key { key, pressed, repeat, modifiers, .. } => {
self.alt_armed = false;
+ // A letter typed with left Alt held continues a sequence (Alt+H, O, A), as
+ // in Excel; any other Alt combination cancels keytips.
+ let alt_letter =
+ self.alt_held && modifiers.alt && !modifiers.shift && !command_modifier(*modifiers) && key.name().len() == 1 && {
+ let next = format!("{}{}", self.prefix.as_deref().unwrap_or_default(), key.name());
+ keytip_table::lookup(&next).is_some() || keytip_table::has_deeper(&next)
+ };
+ if alt_letter && *pressed && self.prefix.is_none() {
+ self.prefix = Some(String::new());
+ }
+ // The release of that letter still carries Alt.
+ let alt_release = !*pressed && self.held.contains(key);
if command_modifier(*modifiers)
- || modifiers.alt
+ || (modifiers.alt && !alt_letter && !alt_release)
|| matches!(key, Key::AltLeft | Key::AltRight | Key::ControlLeft | Key::ControlRight | Key::SuperLeft | Key::SuperRight)
{
self.clear();
diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs
index 24be742a5..1cf286eac 100644
--- a/crates/ui-egui/tests/keytips.rs
+++ b/crates/ui-egui/tests/keytips.rs
@@ -267,6 +267,80 @@ fn mouse_press_and_window_focus_loss_cancel_keytips() {
assert!(h.state().editor.is_none());
}
+fn alt_down(h: &mut Harness, alt: Key) {
+ events(h, [Event::ModifiersChanged(Modifiers::ALT), key_event(alt, true, Modifiers::ALT)]);
+}
+
+fn alt_up(h: &mut Harness, alt: Key) {
+ events(h, [Event::ModifiersChanged(Modifiers::NONE), key_event(alt, false, Modifiers::NONE)]);
+}
+
+fn alt_letter(h: &mut Harness, key: Key) {
+ events(h, [key_event(key, true, Modifiers::ALT), key_event(key, false, Modifiers::ALT)]);
+}
+
+fn line_size(h: &Harness, cols: bool) -> Option {
+ let sh = h.state().session.active().unwrap().wb.active().unwrap();
+ if cols { &sh.cols } else { &sh.rows }.get(&0).and_then(|e| e.size)
+}
+
+#[test]
+fn letters_typed_with_alt_held_run_autofit_sequences() {
+ // #1846: Excel accepts Alt+H, O, A with Alt still held for the first letter (or all of them).
+ for os in [OperatingSystem::Windows, OperatingSystem::Nix] {
+ let mut s = blank();
+ s.execute("cell.set", json!({"cell": "A1", "input": "a much longer piece of text than the column"})).unwrap();
+ s.execute("home.fontSize", json!({"range": "A1", "size": 28})).unwrap();
+ s.execute("home.rowHeight", json!({"rows": "1:1", "height": 10})).unwrap();
+ s.execute("selection.set", json!({"cell": "A1"})).unwrap();
+ let mut h = harness_on(s, os);
+
+ // Alt held through H, O and I: AutoFit Column Width.
+ alt_down(&mut h, Key::AltLeft);
+ alt_letter(&mut h, Key::H);
+ assert_eq!(h.state().keytips.prefix(), Some("H"), "{os:?}: Alt+H opens the Home keytips");
+ alt_letter(&mut h, Key::O);
+ alt_letter(&mut h, Key::I);
+ alt_up(&mut h, Key::AltLeft);
+ assert_eq!(h.state().keytips.prefix(), None, "{os:?}");
+ assert!(line_size(&h, true).is_some_and(|w| w > 100.0), "{os:?}: column A was autofitted");
+
+ // Alt released after H: AutoFit Row Height.
+ alt_down(&mut h, Key::AltLeft);
+ alt_letter(&mut h, Key::H);
+ alt_up(&mut h, Key::AltLeft);
+ assert_eq!(h.state().keytips.prefix(), Some("H"), "{os:?}: releasing Alt keeps the level");
+ letter(&mut h, Key::O, false);
+ letter(&mut h, Key::A, false);
+ assert!(line_size(&h, false).is_some_and(|r| r > 10.0), "{os:?}: row 1 was autofitted");
+ assert_eq!(value(&h, "A1"), Value::Text("a much longer piece of text than the column".into()));
+ assert!(h.state().editor.is_none(), "{os:?}: no letter reached the grid");
+ }
+}
+
+#[test]
+fn alt_combinations_outside_a_sequence_still_cancel_keytips() {
+ let mut h = harness_on(blank(), OperatingSystem::Windows);
+ alt_down(&mut h, Key::AltLeft);
+ alt_letter(&mut h, Key::X);
+ alt_up(&mut h, Key::AltLeft);
+ assert_eq!(h.state().keytips.prefix(), None, "X starts no sequence");
+
+ // AltGr types characters (and is Ctrl+Alt on Windows): it never starts keytips.
+ let mut h = harness_on(blank(), OperatingSystem::Nix);
+ alt_down(&mut h, Key::AltRight);
+ alt_letter(&mut h, Key::H);
+ alt_up(&mut h, Key::AltRight);
+ assert_eq!(h.state().keytips.prefix(), None);
+
+ // macOS: Option is a text modifier (see #1864).
+ let mut h = harness_on(blank(), OperatingSystem::Mac);
+ alt_down(&mut h, Key::AltLeft);
+ alt_letter(&mut h, Key::H);
+ alt_up(&mut h, Key::AltLeft);
+ assert_eq!(h.state().keytips.prefix(), None);
+}
+
#[test]
fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
let mut h = harness(blank());
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 22e221e69..ab0472ba4 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-18 · **Change:** minor (keytip letters typed with Alt held, #1846) · **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
@@ -77,7 +77,7 @@ this logic, with one undo step.
| Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done |
| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing |
| Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) |
-| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded |
+| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; QAT/Backstage excluded |
| Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) |
### Shortcut audit (#110)
@@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
+| 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-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 90e89d1b9faf1e7f7923ce32285ba3e0143ca1a5 Mon Sep 17 00:00:00 2001
From: Shnrqpdr
Date: Mon, 19 Oct 2026 00:14:35 -0300
Subject: [PATCH 22/46] Contributors: add Shnrqpdr to people.toml
---
contributors/people.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/contributors/people.toml b/contributors/people.toml
index d522b84e0..be84b5b01 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.Shnrqpdr]
+sync_github_name = true
From 4db6876c0726e1f0137454e9cee20d867a21ca4d Mon Sep 17 00:00:00 2001
From: justinjohn0306
Date: Mon, 19 Oct 2026 08:59:49 +0530
Subject: [PATCH 23/46] #1855: Backspace then Enter is one undo step
Backspace clears the cell (edit.clearContents) and opens an empty editor.
Committing that editor ran cell.set "" on the already-empty cell, which
recorded a second, empty undo step, so Ctrl+Z appeared to do nothing and
the next Ctrl+Z undid an unrelated earlier edit. Committing empty text
into an empty cell now runs no command.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/lib.rs | 14 +++++++++++++-
crates/ui-egui/tests/interaction.rs | 19 +++++++++++++++++++
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index f96cdac3c..2cbf0510f 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -929,7 +929,19 @@ impl SheetApp {
}
self.toast = Some((msg, now_ms()));
}
- let r = if fill_selection {
+ // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has
+ // already cleared the cell as its own step).
+ let unchanged = !fill_selection
+ && !array
+ && text.is_empty()
+ && self
+ .session
+ .active()
+ .and_then(|d| d.wb.sheet(ed.sheet))
+ .is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty));
+ let r = if unchanged {
+ Ok(json!({}))
+ } else if fill_selection {
self.session.run("range.fill", json!({"input": text}))
} else {
self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array}))
diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs
index b40769cf8..02084ffb8 100644
--- a/crates/ui-egui/tests/interaction.rs
+++ b/crates/ui-egui/tests/interaction.rs
@@ -892,3 +892,22 @@ fn rotated_wrapped_text_is_drawn_rotated_inside_its_cell() {
assert!(clip.width() < 70.0, "clip spans one 6-character column, got {clip:?}");
}
}
+
+#[test]
+fn undo_after_backspace_restores_the_cleared_cell() {
+ // #1855: Backspace clears the cell and opens an empty editor; committing it must not add a
+ // second, empty undo step, so one Ctrl+Z brings the value back and leaves other edits alone.
+ let mut h = harness(blank());
+ text(&mut h, "3200");
+ key(&mut h, Key::Enter, Modifiers::NONE);
+ click_cell(&mut h, "C5");
+ text(&mut h, "11750");
+ key(&mut h, Key::Enter, Modifiers::NONE);
+ click_cell(&mut h, "A1");
+ key(&mut h, Key::Backspace, Modifiers::NONE);
+ key(&mut h, Key::Enter, Modifiers::NONE);
+ assert_eq!(value(&h, "A1"), Value::Empty);
+ key(&mut h, Key::Z, Modifiers::COMMAND);
+ assert_eq!(value(&h, "A1"), Value::Number(3200.0));
+ assert_eq!(value(&h, "C5"), Value::Number(11750.0));
+}
From dcd4d1d13f81d2da24cf703358858646eb2a7a1a Mon Sep 17 00:00:00 2001
From: justinjohn0306
Date: Mon, 19 Oct 2026 10:16:13 +0530
Subject: [PATCH 24/46] #1855: redo selects the redone cell
Undo stored the selection at the moment of undoing (wherever the user had
clicked) in the redo entry, so redo jumped back there instead of to the
cells it changed. The redo entry now keeps the undone step's selection.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/edit.rs | 3 ++-
crates/engine/src/tests.rs | 16 ++++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index c69bac208..f2d0ca521 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -601,7 +601,8 @@ fn undo(s: &mut Session, p: &Json) -> Result {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
- let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
+ // Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
+ let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: e.selection.clone() };
d.redo.push(cur);
label = e.label.clone();
d.wb = e.wb;
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ae499a189..513366848 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -253,6 +253,22 @@ fn undo_redo() {
assert!(s.doc().unwrap().is_dirty());
}
+#[test]
+fn redo_selects_the_redone_cell() {
+ // #1855: type into A1..A3, click A1, undo, redo: the selection follows the edit to A3.
+ let mut s = s();
+ for (cell, text) in [("A1", "first"), ("A2", "second"), ("A3", "third")] {
+ s.execute("selection.set", json!({"cell": cell})).unwrap();
+ s.execute("cell.set", json!({"cell": cell, "input": text})).unwrap();
+ }
+ s.execute("selection.set", json!({"cell": "A1"})).unwrap();
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.a1(), "A3");
+ s.execute("edit.redo", json!({})).unwrap();
+ assert_eq!(v(&s, "A3"), Value::from("third"));
+ assert_eq!(s.doc().unwrap().selection.a1(), "A3");
+}
+
#[test]
fn shape_text_edit_undo_redo_and_noop() {
let mut s = s();
From 0e8e7e34e4cba6f9e6921966890de608dfd7c8e5 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 10:17:42 +0200
Subject: [PATCH 25/46] Pan the grid on a one-finger touch drag instead of
selecting
Fixes #1907
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/grid.rs | 26 ++++++++-
crates/ui-egui/tests/touch_pan.rs | 91 +++++++++++++++++++++++++++++++
2 files changed, 115 insertions(+), 2 deletions(-)
create mode 100644 crates/ui-egui/tests/touch_pan.rs
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 90a39cb06..6f5c5246f 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -149,6 +149,8 @@ pub struct GridState {
pub hscroll: Option,
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
pub wheel_zoom: Option,
+ /// A one-finger touch drag in progress: it pans the sheet instead of selecting.
+ pub touch_pan: bool,
}
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
@@ -641,6 +643,19 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
view.scroll -= d / z;
}
}
+ // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select.
+ // (Two fingers pinch-zoom through `zoom_delta` above.)
+ if resp.drag_started() && ui.input(|i| i.any_touches()) {
+ app.grid.touch_pan = true;
+ }
+ if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) {
+ let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE };
+ let mut d = resp.drag_delta();
+ if sh.right_to_left {
+ d.x = -d.x;
+ }
+ view.scroll -= d / z;
+ }
// The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes
// there at once without animations (as for the selection outline below: View › Animations
// off, egui's off in the headless harness, or a screenshot on its way).
@@ -754,8 +769,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
// Interaction.
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);
+ if app.grid.touch_pan {
+ // Skip the frames of a touch pan, its release included.
+ if !resp.dragged() {
+ app.grid.touch_pan = false;
+ }
+ } else {
+ crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb);
+ interact(app, ui, &resp, &geo, sh, &wb);
+ }
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);
diff --git a/crates/ui-egui/tests/touch_pan.rs b/crates/ui-egui/tests/touch_pan.rs
new file mode 100644
index 000000000..f55e84246
--- /dev/null
+++ b/crates/ui-egui/tests/touch_pan.rs
@@ -0,0 +1,91 @@
+//! Touch input on the grid: one finger pans, a tap selects (issue #1907).
+
+use egui::{Event, Modifiers, PointerButton, TouchDeviceId, TouchId, TouchPhase};
+use gridcraft_engine::Session;
+use gridcraft_engine::core::CellRef;
+use gridcraft_ui_egui::SheetApp;
+
+fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> {
+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+ |ui, app: &mut SheetApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ SheetApp::new(session, Default::default()),
+ );
+ h.run_steps(4);
+ h
+}
+
+fn blank() -> Session {
+ let mut s = Session::new();
+ s.new_workbook();
+ s
+}
+
+/// One finger, sent the way egui-winit does: a touch event plus the emulated pointer events.
+fn touch(h: &mut egui_kittest::Harness<'static, SheetApp>, phase: TouchPhase, pos: egui::Pos2) {
+ let ev = &mut h.input_mut().events;
+ ev.push(Event::Touch { device_id: TouchDeviceId(1), id: TouchId(0), phase, pos, force: None });
+ match phase {
+ TouchPhase::Start => {
+ ev.push(Event::PointerMoved(pos));
+ ev.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+ }
+ TouchPhase::Move => ev.push(Event::PointerMoved(pos)),
+ TouchPhase::End | TouchPhase::Cancel => {
+ ev.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
+ ev.push(Event::PointerGone);
+ }
+ }
+}
+
+fn swipe(h: &mut egui_kittest::Harness<'static, SheetApp>, from: egui::Pos2, by: egui::Vec2) {
+ touch(h, TouchPhase::Start, from);
+ h.run_steps(2);
+ for i in 1..=10 {
+ touch(h, TouchPhase::Move, from + by * (i as f32 / 10.0));
+ h.run_steps(1);
+ }
+ touch(h, TouchPhase::End, from + by);
+ h.run_steps(3);
+}
+
+fn assert_selection_untouched(h: &egui_kittest::Harness<'static, SheetApp>) {
+ let sel = &h.state().session.active().unwrap().selection;
+ assert_eq!(sel.active, CellRef::new(0, 0), "a touch pan must not move the active cell: {sel:?}");
+ assert_eq!(sel.ranges.len(), 1, "{sel:?}");
+ assert_eq!(sel.ranges[0].start, CellRef::new(0, 0), "{sel:?}");
+ assert_eq!(sel.ranges[0].end, CellRef::new(0, 0), "a touch pan must not select cells: {sel:?}");
+}
+
+#[test]
+fn one_finger_drag_pans_the_grid_instead_of_selecting() {
+ let mut h = harness(blank());
+ let cells = h.state().grid.cells_rect.unwrap();
+ swipe(&mut h, cells.center() + egui::vec2(0.0, 100.0), egui::vec2(0.0, -200.0));
+ let scroll = h.state().view().scroll;
+ assert!(scroll.y > 0.0, "dragging a finger up scrolls down: scroll = {scroll:?}");
+ assert_selection_untouched(&h);
+
+ swipe(&mut h, cells.center() + egui::vec2(100.0, 0.0), egui::vec2(-200.0, 0.0));
+ let scroll = h.state().view().scroll;
+ assert!(scroll.x > 0.0, "dragging a finger left scrolls right: scroll = {scroll:?}");
+ assert_selection_untouched(&h);
+}
+
+#[test]
+fn one_finger_tap_still_selects_the_cell() {
+ let mut h = harness(blank());
+ let cells = h.state().grid.cells_rect.unwrap();
+ let p = cells.min + egui::vec2(5.0, 5.0) + egui::vec2(0.0, cells.height() / 2.0);
+ touch(&mut h, TouchPhase::Start, p);
+ h.run_steps(1);
+ touch(&mut h, TouchPhase::End, p);
+ h.run_steps(3);
+ let sel = &h.state().session.active().unwrap().selection;
+ assert_eq!(sel.active.col, 0, "{sel:?}");
+ assert!(sel.active.row > 0, "a tap selects the tapped cell: {sel:?}");
+ assert_eq!(h.state().view().scroll, egui::Vec2::ZERO);
+}
From adcb460a63552d5fd6d134b7d69adf8a1050b23b Mon Sep 17 00:00:00 2001
From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com>
Date: Mon, 19 Oct 2026 10:54:50 +0200
Subject: [PATCH 26/46] i18n: the test also checks Page Layout's Sheet Options
labels (#1891)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From CodeRabbit's review of #1892: the fix also strips the prefix from
"Sheet Options|View" (the Gridlines and Headings checkboxes), but the test
only rendered File › Options. It now opens Page Layout in French and
Serbian too and checks that the checkboxes read "Affichage" / "Prikaz",
not "Options de la feuille|Affichage" / "Opcije lista|Prikaz". It fails
with the old catalog values.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/tests/file_menu.rs | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs
index d4aa13c29..6cce9cdba 100644
--- a/crates/ui-egui/tests/file_menu.rs
+++ b/crates/ui-egui/tests/file_menu.rs
@@ -134,9 +134,9 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
#[test]
fn translated_options_headings_show_no_context_prefix() {
- for (code, headings, prefixed) in [
- ("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"]),
- ("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"]),
+ for (code, headings, prefixed, (view, view_prefixed)) in [
+ ("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")),
+ ("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")),
] {
let mut h = harness(Services::default());
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
@@ -149,6 +149,13 @@ fn translated_options_headings_show_no_context_prefix() {
for label in prefixed {
assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
}
+ // Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same
+ // prefix. The View tab may share the word, so count at least the two checkboxes.
+ h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
+ h.state_mut().ui.ribbon_tab = "Page Layout".into();
+ h.run_steps(3);
+ assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context");
+ assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}");
}
}
From cc21643b83a90b9afd3d88d9a2dfe2d04f6bae7f Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 09:44:48 +0000
Subject: [PATCH 27/46] Reuse one D-Bus session connection in the desktop theme
watcher
Fixes #1915
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/ui-egui/src/desktop_theme.rs | 66 ++++++++++++++++++++++++-----
1 file changed, 56 insertions(+), 10 deletions(-)
diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs
index 2eb5d8371..7b836d2a4 100644
--- a/crates/ui-egui/src/desktop_theme.rs
+++ b/crates/ui-egui/src/desktop_theme.rs
@@ -16,7 +16,7 @@ const POLL: Duration = Duration::from_secs(4);
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
/// keeps whatever it chose itself.
pub fn prefers_dark() -> Option {
- read()
+ read(&mut Link::default())
}
/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the
@@ -33,13 +33,14 @@ impl DesktopTheme {
pub fn start() -> Self {
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("gridcraft-desktop-theme".into()).spawn(move || {
- let mut last = read();
+ let mut link = Link::default();
+ let mut last = read(&mut link);
if tx.send(last).is_err() {
return;
}
loop {
std::thread::sleep(POLL);
- let now = read();
+ let now = read(&mut link);
if now != last {
last = now;
if tx.send(now).is_err() {
@@ -66,17 +67,32 @@ impl DesktopTheme {
}
}
-/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
-/// thread is its only caller.
+/// The session-bus connection the watcher keeps between reads, opened on first use.
#[cfg(target_os = "linux")]
-fn read() -> Option {
+type Link = Option;
+#[cfg(not(target_os = "linux"))]
+type Link = ();
+
+/// Ask the portal once, over `link`'s connection (opened if absent, dropped if the bus went
+/// away). D-Bus calls block, so this belongs off the UI thread: the watcher's thread is its
+/// only repeated caller.
+#[cfg(target_os = "linux")]
+fn read(link: &mut Link) -> Option {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
- let connection = zbus::blocking::Connection::session().ok()?;
+ if link.is_none() {
+ *link = Some(zbus::blocking::Connection::session().ok()?);
+ }
+ let connection = link.as_ref()?;
for (schema, key) in SCHEMAS {
- let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else {
- continue;
+ let reply = match connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) {
+ Ok(reply) => reply,
+ Err(zbus::Error::InputOutput(_)) => {
+ *link = None;
+ return None;
+ }
+ Err(_) => continue,
};
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
if let Ok(scheme) = String::try_from(&*answer) {
@@ -99,7 +115,7 @@ fn parse(scheme: &str) -> Option {
/// macOS and Windows answer through winit, which the caller already has.
#[cfg(not(target_os = "linux"))]
-fn read() -> Option {
+fn read(_: &mut Link) -> Option {
None
}
@@ -124,6 +140,36 @@ mod tests {
assert_eq!(prefers_dark(), None);
}
+ /// The watcher keeps one session-bus connection instead of opening one per poll. Runs the
+ /// check in a child process under a private `dbus-run-session` bus, so bus names are ours.
+ #[test]
+ #[cfg(target_os = "linux")]
+ fn the_watcher_reuses_one_bus_connection() {
+ const INNER: &str = "GRIDCRAFT_THEME_INNER";
+ let unique = |c: &zbus::blocking::Connection| {
+ c.unique_name().map(|n| n.as_str().rsplit('.').next().and_then(|s| s.parse::().ok()).unwrap_or(0)).unwrap_or(0)
+ };
+ if std::env::var_os(INNER).is_none() {
+ let exe = std::env::current_exe().expect("cannot locate the test executable to re-run it under dbus-run-session");
+ let out = std::process::Command::new("dbus-run-session")
+ .arg("--")
+ .arg(exe)
+ .args(["--exact", "desktop_theme::tests::the_watcher_reuses_one_bus_connection", "--nocapture"])
+ .env(INNER, "1")
+ .output()
+ .expect("dbus-run-session is required to run this test (install dbus-daemon / dbus-x11)");
+ assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
+ return;
+ }
+ let probe = zbus::blocking::Connection::session().expect("no session bus under dbus-run-session");
+ let before = unique(&probe);
+ let _watcher = DesktopTheme::start();
+ std::thread::sleep(POLL * 2 + Duration::from_secs(1));
+ let after_probe = zbus::blocking::Connection::session().expect("no session bus under dbus-run-session");
+ let made = unique(&after_probe) - before - 1;
+ assert!(made <= 1, "the watcher opened {made} bus connections in three reads");
+ }
+
/// A watcher that was never started is inert rather than a panic.
#[test]
fn a_watcher_without_a_thread_is_inert() {
From 86641d965b60c5a2c5895973562b33d901e257a7 Mon Sep 17 00:00:00 2001
From: MH
Date: Mon, 19 Oct 2026 12:52:52 +0300
Subject: [PATCH 28/46] Grid: explicit Left/Right alignment keeps its side on a
right-to-left sheet
Cells are painted left to right and a right-to-left sheet is mirrored
afterwards, so an explicit Align Right ended up on the left (and Align
Left on the right). Excel keeps an explicit alignment on the side it
names in either sheet direction; General alignment follows the sheet.
The alignment is now swapped before painting (painted_halign), for
straight and rotated text alike.
Signed-off-by: MH
---
crates/ui-egui/src/grid.rs | 30 ++++++++++++++++++++++++++++--
1 file changed, 28 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 90a39cb06..d5d9f030e 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -1178,7 +1178,7 @@ pub(crate) fn paint_quadrant(
_ if numeric => HAlign::Right,
_ => HAlign::Left,
},
- h => h,
+ h => painted_halign(h, geo.rtl),
};
let indent = st.align.indent as f32 * 9.0 * z;
let pad = 2.5 * z;
@@ -1296,7 +1296,7 @@ pub(crate) fn paint_quadrant(
} else {
galley.clone()
};
- let pos = rotated_text_pos(*rect, galley.size(), angle, st.align.h, st.align.v, pad, indent);
+ let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent);
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
shape.angle = angle;
cp.add(shape);
@@ -3336,8 +3336,34 @@ mod selection_fill_tests {
}
}
+/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left
+/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the
+/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel.
+/// General alignment is resolved from the value before this and follows the sheet direction.
+fn painted_halign(h: HAlign, rtl: bool) -> HAlign {
+ match h {
+ HAlign::Left if rtl => HAlign::Right,
+ HAlign::Right if rtl => HAlign::Left,
+ h => h,
+ }
+}
+
#[cfg(test)]
mod tests {
+
+ #[test]
+ fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() {
+ use super::{HAlign, painted_halign};
+ // Painted before the mirror: swapped, so they land where the user put them.
+ assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left);
+ assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right);
+ // Left-to-right sheets and the other alignments are painted as they are.
+ assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right);
+ assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left);
+ for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] {
+ assert_eq!(painted_halign(h, true), h);
+ }
+ }
use super::*;
#[test]
From 62e52004f83d80a11091d293732fc524d81d9691 Mon Sep 17 00:00:00 2001
From: MH
Date: Mon, 19 Oct 2026 13:01:51 +0300
Subject: [PATCH 29/46] Keyboard: Ctrl+Shift+= and Ctrl+- act on whole rows and
columns at once
With whole rows or whole columns selected, Excel inserts or deletes them
directly; it asks which way to shift only for other selections. Both
shortcuts always opened the Insert or Delete dialog, an extra step for
the most common case.
Signed-off-by: MH
---
crates/ui-egui/src/ribbon.rs | 41 +++++++++---
crates/ui-egui/tests/whole_line_shortcuts.rs | 65 ++++++++++++++++++++
2 files changed, 98 insertions(+), 8 deletions(-)
create mode 100644 crates/ui-egui/tests/whole_line_shortcuts.rs
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
index 9c6d89eeb..d7c6bd697 100644
--- a/crates/ui-egui/src/ribbon.rs
+++ b/crates/ui-egui/src/ribbon.rs
@@ -2452,14 +2452,24 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
Key::Num9 => Some(("home.hideRows", json!({}))),
Key::Num0 if shift => Some(("home.unhideColumns", json!({}))),
Key::Num0 => Some(("home.hideColumns", json!({}))),
- Key::Minus => {
- app.open_dialog("deleteCells", json!({}));
- None
- }
- Key::Plus | Key::Equals if shift => {
- app.open_dialog("insertCells", json!({}));
- None
- }
+ // As in Excel: with whole rows or whole columns selected, Ctrl+Shift+= / Ctrl+- insert or
+ // delete them at once; any other selection asks which way to shift.
+ Key::Minus => match whole_lines(app) {
+ Some(true) => Some(("home.deleteRows", json!({}))),
+ Some(false) => Some(("home.deleteColumns", json!({}))),
+ None => {
+ app.open_dialog("deleteCells", json!({}));
+ None
+ }
+ },
+ Key::Plus | Key::Equals if shift => match whole_lines(app) {
+ Some(true) => Some(("home.insertRows", json!({}))),
+ Some(false) => Some(("home.insertColumns", json!({}))),
+ None => {
+ app.open_dialog("insertCells", json!({}));
+ None
+ }
+ },
Key::Semicolon => {
// Insert today's date.
let text = app
@@ -2514,3 +2524,18 @@ mod checkbox_on_tests {
assert!(!checkbox_on(false, true));
}
}
+
+/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole
+/// columns, `None` otherwise (including the whole sheet).
+fn whole_lines(app: &SheetApp) -> Option {
+ let sel = &app.session.active()?.selection;
+ let rows = sel.ranges.iter().all(|r| r.is_full_rows() && !r.is_full_cols());
+ let cols = sel.ranges.iter().all(|r| r.is_full_cols() && !r.is_full_rows());
+ if rows {
+ Some(true)
+ } else if cols {
+ Some(false)
+ } else {
+ None
+ }
+}
diff --git a/crates/ui-egui/tests/whole_line_shortcuts.rs b/crates/ui-egui/tests/whole_line_shortcuts.rs
new file mode 100644
index 000000000..2bbcf3adf
--- /dev/null
+++ b/crates/ui-egui/tests/whole_line_shortcuts.rs
@@ -0,0 +1,65 @@
+//! Ctrl+Shift+= and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel;
+//! any other selection opens the Insert or Delete dialog.
+
+use egui::{Key, Modifiers};
+use gridcraft_engine::Session;
+use gridcraft_engine::core::{CellRef, Value};
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+type Harness = egui_kittest::Harness<'static, SheetApp>;
+
+fn harness(selection: &str) -> Harness {
+ let mut session = Session::new();
+ session.new_workbook();
+ session.execute("range.setValues", json!({"range": "A1", "values": [["a1", "b1"], ["a2", "b2"], ["a3", "b3"]]})).unwrap();
+ session.execute("selection.set", json!({"range": selection})).unwrap();
+ let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+ |ui, app: &mut SheetApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ SheetApp::new(session, Default::default()),
+ );
+ h.run_steps(4);
+ h
+}
+
+fn value(h: &Harness, a1: &str) -> Value {
+ h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(a1).unwrap())
+}
+
+fn press(h: &mut Harness, key: Key, shift: bool) {
+ let modifiers = Modifiers { shift, ..Modifiers::COMMAND };
+ h.key_press_modifiers(modifiers, key);
+ h.run_steps(3);
+}
+
+#[test]
+fn whole_rows_are_inserted_and_deleted_at_once() {
+ let mut h = harness("2:2");
+ press(&mut h, Key::Equals, true);
+ assert!(h.state().dialog.is_none(), "no dialog for whole rows");
+ assert!(value(&h, "A2").is_empty(), "a row was inserted above row 2");
+ assert_eq!(value(&h, "A3"), Value::from("a2"));
+ press(&mut h, Key::Minus, false);
+ assert_eq!(value(&h, "A2"), Value::from("a2"), "and deleted again");
+}
+
+#[test]
+fn whole_columns_are_inserted_and_deleted_at_once() {
+ let mut h = harness("A:A");
+ press(&mut h, Key::Equals, true);
+ assert!(h.state().dialog.is_none(), "no dialog for whole columns");
+ assert_eq!(value(&h, "B1"), Value::from("a1"));
+ press(&mut h, Key::Minus, false);
+ assert_eq!(value(&h, "A1"), Value::from("a1"));
+}
+
+#[test]
+fn other_selections_still_ask_which_way_to_shift() {
+ let mut h = harness("A2:B2");
+ press(&mut h, Key::Minus, false);
+ assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("deleteCells"));
+}
From 28cdf1d18744a28e29d93e8cdf37c0e5bace7425 Mon Sep 17 00:00:00 2001
From: MH
Date: Mon, 19 Oct 2026 13:08:41 +0300
Subject: [PATCH 30/46] Fix two clippy lints new in Rust 1.95
CI installs the stable toolchain, and Rust 1.95's clippy fails
'cargo clippy --workspace --all-targets -- -D warnings' on main:
- xls/mod.rs: collapsible_match. The XF record's size check becomes a
match guard; a full table still ignores the record (the catch-all arm).
- passthrough_tests.rs: nonminimal_bool. !is_some_and(x) is written as
is_none_or(!x).
No change in behaviour.
Signed-off-by: MH
---
crates/xlsx/src/tests/passthrough_tests.rs | 2 +-
crates/xlsx/src/xls/mod.rs | 8 +++-----
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs
index d844daa05..eff83703e 100644
--- a/crates/xlsx/src/tests/passthrough_tests.rs
+++ b/crates/xlsx/src/tests/passthrough_tests.rs
@@ -477,7 +477,7 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() {
+ wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::();
assert!(kept <= one + VML.len() / 2, "kept {kept} bytes");
assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some()));
- assert!(!wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some()));
+ assert!(wb.sheet(1).unwrap().preserved.as_ref().is_none_or(|p| p.vml.is_none()));
assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings);
// Both sheets keep their drawings when there is room.
let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap();
diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs
index dde8641e5..47cceffdc 100644
--- a/crates/xlsx/src/xls/mod.rs
+++ b/crates/xlsx/src/xls/mod.rs
@@ -269,11 +269,9 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul
budget.text(code.len())?;
g.formats.insert(ifmt, code);
}
- 0x00E0 => {
- if g.xf_formats.len() < MAX_XFS {
- c.u16()?;
- g.xf_formats.push(c.u16()?);
- }
+ 0x00E0 if g.xf_formats.len() < MAX_XFS => {
+ c.u16()?;
+ g.xf_formats.push(c.u16()?);
}
0x00FC => g.sst = shared_strings(&mut c, budget)?,
0x01AE => {
From 6ac8ad92166534f608c56816ca06dffd775646f7 Mon Sep 17 00:00:00 2001
From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:18:34 +0200
Subject: [PATCH 31/46] fix(edit): Ctrl+Enter gives each cell the number format
typing gives it
range.fill parsed the input once at the active cell, then put back the old
style of every target that already held a cell, so 5%, $5, 3/4/2026 or
=DATE(...) lost their automatic format there (0.05, 5, 46085), and empty
targets took the active cell's style. Each target now gets what
input_to_cell gives for it (parsed once per distinct style), as typing does.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/edit.rs | 17 ++++-
crates/engine/tests/range_fill_formats.rs | 93 +++++++++++++++++++++++
docs/ui-parity.md | 3 +-
3 files changed, 109 insertions(+), 4 deletions(-)
create mode 100644 crates/engine/tests/range_fill_formats.rs
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index c69bac208..d4df2922e 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -338,20 +338,31 @@ fn range_fill(s: &mut Session, p: &Json) -> Result {
let input = str_param(p, "input").unwrap_or("").to_string();
let origin = s.doc()?.selection.active;
edit(s, |cx| {
- let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
+ // Each cell gets what typing the input there gives: the automatic number format where
+ // it's General, its own format otherwise, text where it's Text. That depends only on
+ // the cell's style, so the input is parsed once per style.
+ let mut by_style: std::collections::HashMap> = std::collections::HashMap::new();
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
for at in r.iter() {
- let mut cell = base.clone().unwrap_or_default();
+ let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
+ let base = match by_style.get(&style) {
+ Some(c) => c.clone(),
+ None => {
+ let c = input_to_cell(&input, sheet, at, &mut cx.wb)?;
+ by_style.insert(style, c.clone());
+ c
+ }
+ };
+ let mut cell = base.unwrap_or_default();
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
- cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style);
cx.sheet_mut(sheet)?.set_cell(at, cell);
cx.touch(sheet, at);
}
diff --git a/crates/engine/tests/range_fill_formats.rs b/crates/engine/tests/range_fill_formats.rs
new file mode 100644
index 000000000..ce1d19fb6
--- /dev/null
+++ b/crates/engine/tests/range_fill_formats.rs
@@ -0,0 +1,93 @@
+//! Ctrl+Enter (`range.fill`) gives every cell what typing the input there gives it: a General
+//! cell gets the input's automatic number format, a formatted cell keeps its own.
+
+use gridcraft_engine::Session;
+use serde_json::{Value, json};
+
+fn session() -> Session {
+ let mut s = Session::new();
+ s.new_workbook();
+ s
+}
+
+fn get(s: &mut Session, a: &str) -> Value {
+ s.execute("cell.get", json!({"cell": a})).unwrap()
+}
+
+fn fmt_of(s: &Session, a: &str) -> String {
+ let wb = &s.doc().unwrap().wb;
+ let sh = wb.active().unwrap();
+ wb.styles.get(sh.style_id(gridcraft_engine::core::CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
+}
+
+/// Selects `range` with A1 active and fills it with `input`, as Ctrl+Enter does.
+fn fill(s: &mut Session, range: &str, input: &str) {
+ s.execute("selection.set", json!({"range": range, "active": "A1"})).unwrap();
+ s.execute("range.fill", json!({"input": input})).unwrap();
+}
+
+#[test]
+fn filled_cells_that_already_hold_a_value_get_the_automatic_format() {
+ for input in ["5%", "$5", "3/4/2026", "1:30 PM", "1.5E+3"] {
+ let mut s = session();
+ // What typing the input into a General cell gives.
+ s.execute("cell.set", json!({"cell": "C1", "input": input})).unwrap();
+ let typed = get(&mut s, "C1");
+ // A1 blank, A2 holding a number: both are General.
+ s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
+ fill(&mut s, "A1:A2", input);
+ for a in ["A1", "A2"] {
+ let filled = get(&mut s, a);
+ assert_eq!(filled["text"], typed["text"], "{input} in {a}");
+ assert_eq!(filled["value"], typed["value"], "{input} in {a}");
+ assert_eq!(fmt_of(&s, a), fmt_of(&s, "C1"), "{input} in {a}");
+ }
+ }
+}
+
+#[test]
+fn filled_cells_with_their_own_format_keep_it() {
+ let mut s = session();
+ s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
+ s.execute("home.formatCells", json!({"range": "A2", "style": {"num_fmt": "0.000"}})).unwrap();
+ // Column B is formatted but has no cells yet; row 3 is Text.
+ s.execute("home.formatCells", json!({"range": "B:B", "style": {"num_fmt": "0.00"}})).unwrap();
+ s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@"}})).unwrap();
+ fill(&mut s, "A1:B3", "5%");
+ assert_eq!(fmt_of(&s, "A1"), "0%");
+ assert_eq!(get(&mut s, "A1")["text"], "5%");
+ assert_eq!(fmt_of(&s, "A2"), "0.000");
+ assert_eq!(get(&mut s, "A2")["text"], "0.050");
+ assert_eq!(fmt_of(&s, "B1"), "0.00");
+ assert_eq!(get(&mut s, "B1")["text"], "0.05");
+ // A Text cell keeps what was typed, as text.
+ for a in ["A3", "B3"] {
+ assert_eq!(get(&mut s, a)["type"], "text", "{a}");
+ assert_eq!(get(&mut s, a)["text"], "5%", "{a}");
+ }
+}
+
+#[test]
+fn filled_formulas_still_shift_and_get_their_date_format() {
+ let mut s = session();
+ s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
+ fill(&mut s, "A1:A2", "=DATE(2026,3,ROW())");
+ assert_eq!(get(&mut s, "A2")["formula"], "=DATE(2026,3,ROW())");
+ for (a, text) in [("A1", "3/1/2026"), ("A2", "3/2/2026")] {
+ assert_eq!(fmt_of(&s, a), "m/d/yyyy", "{a}");
+ assert_eq!(get(&mut s, a)["text"], text, "{a}");
+ }
+ fill(&mut s, "A1:A2", "=C1*2");
+ assert_eq!(get(&mut s, "A2")["formula"], "=C2*2");
+}
+
+#[test]
+fn a_fill_undoes_in_one_step() {
+ let mut s = session();
+ s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
+ fill(&mut s, "A1:A2", "5%");
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(get(&mut s, "A1")["type"], "empty");
+ assert_eq!(get(&mut s, "A2")["value"].as_f64(), Some(1.0));
+ assert_eq!(fmt_of(&s, "A2"), "General");
+}
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 22e221e69..4256d43cc 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-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it) · **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
@@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
+| 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-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 620be76873d21f934620aea24cdde6ba49f464c2 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 10:48:47 +0000
Subject: [PATCH 32/46] Reject signed and multi-$ whole-row ranges in
RangeRef::parse
Found while testing crates/core/src/addr.rs:217
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/core/src/addr.rs | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs
index 2bdfe797a..4780158e8 100644
--- a/crates/core/src/addr.rs
+++ b/crates/core/src/addr.rs
@@ -214,7 +214,14 @@ impl RangeRef {
if let (Some(x), Some(y)) = (letters_to_col(&strip(a)), letters_to_col(&strip(b))) {
return Some(RangeRef::cols(x.min(y), x.max(y)));
}
- let (pa, pb) = (strip(a).parse::().ok()?, strip(b).parse::().ok()?);
+ let row_num = |t: &str| {
+ let t = t.strip_prefix('$').unwrap_or(t);
+ if t.is_empty() || !t.bytes().all(|c| c.is_ascii_digit()) {
+ return None;
+ }
+ t.parse::().ok()
+ };
+ let (pa, pb) = (row_num(a)?, row_num(b)?);
if pa == 0 || pb == 0 || pa > MAX_ROWS || pb > MAX_ROWS {
return None;
}
@@ -234,6 +241,16 @@ impl fmt::Display for RangeRef {
mod tests {
use super::*;
+ #[test]
+ fn range_parse_rejects_signed_rows() {
+ assert!(RangeRef::parse("+1:+3").is_none());
+ assert!(RangeRef::parse("-1:3").is_none());
+ assert!(RangeRef::parse("$$1:3").is_none());
+ assert!(RangeRef::parse(":3").is_none());
+ let r = RangeRef::parse("$1:$3").unwrap();
+ assert_eq!((r.start.row, r.end.row), (0, 2));
+ }
+
#[test]
fn letters_roundtrip() {
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
From fb7b23dbade9c10738ab731be25ba924fe90996e Mon Sep 17 00:00:00 2001
From: Mouhand-Kaddo
Date: Mon, 19 Oct 2026 14:05:16 +0400
Subject: [PATCH 33/46] M12.AR8: count characters, not bytes, in name length
limits
---
crates/engine/src/cmd/formulas.rs | 16 +++++++++++++++-
docs/localization-parity.md | 6 +++++-
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs
index 40df72df0..454c545d0 100644
--- a/crates/engine/src/cmd/formulas.rs
+++ b/crates/engine/src/cmd/formulas.rs
@@ -229,7 +229,7 @@ fn valid_name(n: &str) -> bool {
if !(first.is_alphabetic() || first == '_' || first == '\\') {
return false;
}
- if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
+ if n.chars().count() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
return false;
}
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
@@ -610,4 +610,18 @@ 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 arabic_defined_names_count_characters_not_bytes() {
+ let mut s = Session::new();
+ s.new_workbook();
+ let arabic = |n: usize| "س".repeat(n);
+ for ok in [200, 255] {
+ s.execute("formulas.defineName", json!({"name": arabic(ok), "refersTo": "=1"})).unwrap();
+ }
+ assert!(s.execute("formulas.defineName", json!({"name": arabic(256), "refersTo": "=1"})).is_err());
+ s.execute("range.setValues", json!({"range": "A1", "values": [[format!("ع{}", arabic(199))], [1]]})).unwrap();
+ let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap();
+ assert_eq!(r["created"], 1);
+ }
}
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..6885a595a 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -33,6 +33,9 @@ right-to-left sheets and full complex-script support. Part of
arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box
(no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. IME input goes
through egui/winit and is unverified for Chinese, Japanese and Korean.
+- **Names in any script.** Defined names (Define Name, Create from Selection) are limited to
+ 255 characters, counted as characters rather than UTF-8 bytes, so a 255-letter Arabic name is
+ accepted as in Excel. Sheet names already counted characters (31).
- **PDF export** uses only the 14 standard PDF fonts: non-Latin text cannot export.
## Languages
@@ -78,6 +81,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | Defined-name length counts characters, not bytes: Arabic names up to 255 letters are accepted, and Create from Selection keeps long Arabic headers |
| 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 90344445d762201a39f4fb1f1c5611cd337de855 Mon Sep 17 00:00:00 2001
From: Mouhand-Kaddo
Date: Mon, 19 Oct 2026 15:46:21 +0400
Subject: [PATCH 34/46] M12.AR10: keep right-to-left paragraph direction in
text boxes and shapes
---
crates/model/src/features.rs | 4 +-
crates/xlsx/src/drawing.rs | 19 +++---
crates/xlsx/src/tests/mod.rs | 1 +
crates/xlsx/src/tests/roundtrip.rs | 1 +
crates/xlsx/src/tests/shape_text_rtl.rs | 86 +++++++++++++++++++++++++
docs/file-format-parity.md | 3 +-
docs/localization-parity.md | 7 +-
7 files changed, 109 insertions(+), 12 deletions(-)
create mode 100644 crates/xlsx/src/tests/shape_text_rtl.rs
diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs
index b51f7bc98..c35254f0e 100644
--- a/crates/model/src/features.rs
+++ b/crates/model/src/features.rs
@@ -564,7 +564,7 @@ pub enum ShapeTextAnchor {
}
/// How a shape's outline and text look. The text properties apply to the whole text (the first
-/// run's properties when a file has several).
+/// paragraph's and first run's properties when a file has several).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ShapeFormat {
@@ -584,6 +584,8 @@ pub struct ShapeFormat {
pub align: Option,
/// `None`: the shape's default (top for text boxes, middle for other shapes).
pub anchor: Option,
+ /// Paragraphs run right to left (`a:pPr rtl`).
+ pub rtl: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs
index 6dbd17e7e..a6892f0b8 100644
--- a/crates/xlsx/src/drawing.rs
+++ b/crates/xlsx/src/drawing.rs
@@ -228,8 +228,8 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option {
}
/// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's
-/// alignment (`a:pPr algn`) and the first run's properties (`a:rPr`: size, bold, italic, fill,
-/// outline, `a:latin` typeface).
+/// alignment and direction (`a:pPr algn`, `rtl`) and the first run's properties (`a:rPr`: size,
+/// bold, italic, fill, outline, `a:latin` typeface).
fn read_text_format(body: Option<&El>) -> ShapeFormat {
let mut f = ShapeFormat::default();
let Some(body) = body else { return f };
@@ -238,12 +238,13 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat {
"b" => ShapeTextAnchor::Bottom,
_ => ShapeTextAnchor::Top,
});
- let first_p = body.kids("p").next();
- f.align = first_p.and_then(|p| p.child("pPr")).and_then(|p| p.attr("algn")).map(|a| match a {
+ let ppr = body.kids("p").next().and_then(|p| p.child("pPr"));
+ f.align = ppr.and_then(|p| p.attr("algn")).map(|a| match a {
"ctr" => ShapeTextAlign::Center,
"r" => ShapeTextAlign::Right,
_ => ShapeTextAlign::Left,
});
+ f.rtl = ppr.is_some_and(|p| p.flag("rtl", false));
let rpr = body.kids("p").flat_map(|p| p.kids("r")).find_map(|r| r.child("rPr"));
if let Some(r) = rpr {
// `sz` is in hundredths of a point (100..=400000 in the schema).
@@ -415,12 +416,14 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
None => "",
};
let _ = write!(s, "");
- let ppr = match f.align {
+ let algn = match f.align {
None => "",
- Some(ShapeTextAlign::Left) => "",
- Some(ShapeTextAlign::Center) => "",
- Some(ShapeTextAlign::Right) => "",
+ Some(ShapeTextAlign::Left) => " algn=\"l\"",
+ Some(ShapeTextAlign::Center) => " algn=\"ctr\"",
+ Some(ShapeTextAlign::Right) => " algn=\"r\"",
};
+ let rtl = if f.rtl { " rtl=\"1\"" } else { "" };
+ let ppr = if algn.is_empty() && rtl.is_empty() { String::new() } else { format!("") };
let (rpr_attrs, rpr_body) = run_props(f, theme);
for line in sh.text.split('\n') {
if line.is_empty() {
diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs
index 517113d0f..bdbd66e05 100644
--- a/crates/xlsx/src/tests/mod.rs
+++ b/crates/xlsx/src/tests/mod.rs
@@ -13,6 +13,7 @@ mod passthrough_tests;
mod pivot_tests;
mod richdata_tests;
mod roundtrip;
+mod shape_text_rtl;
mod sheet_names;
mod smooth_roundtrip;
mod trendline_roundtrip;
diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs
index 901802905..7c7c19056 100644
--- a/crates/xlsx/src/tests/roundtrip.rs
+++ b/crates/xlsx/src/tests/roundtrip.rs
@@ -365,6 +365,7 @@ fn sample() -> Workbook {
text_outline: Some((Color::Rgb(0xFF0000), 1.5)),
align: Some(ShapeTextAlign::Right),
anchor: Some(ShapeTextAnchor::Bottom),
+ rtl: true,
},
});
let base_chart = Chart {
diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs
new file mode 100644
index 000000000..36bdb861d
--- /dev/null
+++ b/crates/xlsx/src/tests/shape_text_rtl.rs
@@ -0,0 +1,86 @@
+//! Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`).
+
+use std::io::Read;
+use std::sync::Arc;
+
+use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, Sheet, Workbook};
+
+use super::minimal;
+use crate::{read_xlsx, write_xlsx};
+
+const ARABIC_TEXT_BOX: &str = r#"
+10104040
+
+
+
+مرحبا بالعالم
+"#;
+
+fn read_drawing(drawing: &str) -> Workbook {
+ let rels = r#""#;
+ let bytes = minimal(
+ r#""#,
+ &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)],
+ "",
+ "",
+ );
+ read_xlsx(&bytes).unwrap().0
+}
+
+fn written_drawing(wb: &Workbook) -> String {
+ let bytes = write_xlsx(wb).unwrap();
+ let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
+ let mut drawing = String::new();
+ archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap();
+ drawing
+}
+
+fn text_box(text: &str, format: ShapeFormat) -> Workbook {
+ let mut sheet = Sheet::new("Sheet1");
+ sheet.shapes.push(Shape {
+ id: 1,
+ kind: ShapeKind::TextBox,
+ anchor: Anchor { width: 120.0, height: 40.0, ..Default::default() },
+ fill: Color::Auto,
+ line: Color::Auto,
+ text: text.into(),
+ format,
+ });
+ Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() }
+}
+
+#[test]
+fn shape_text_keeps_right_to_left_paragraphs() {
+ let wb = read_drawing(ARABIC_TEXT_BOX);
+ let shape = &wb.sheet(0).unwrap().shapes[0];
+ assert_eq!(shape.text, "مرحبا بالعالم");
+ assert!(shape.format.rtl, "rtl=\"1\" on the first paragraph is read");
+ assert_eq!(shape.format.align, Some(ShapeTextAlign::Right));
+
+ let xml = written_drawing(&wb);
+ assert!(xml.contains(r#""#), "saved paragraph keeps rtl: {xml}");
+
+ let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
+ let after = &reopened.sheet(0).unwrap().shapes[0];
+ assert!(after.format.rtl);
+ assert_eq!(after.format.align, Some(ShapeTextAlign::Right));
+ assert_eq!(after.text, "مرحبا بالعالم");
+
+ let xml = written_drawing(&text_box("سطر\nآخر", ShapeFormat { rtl: true, ..Default::default() }));
+ assert_eq!(xml.matches(r#""#).count(), 2, "every paragraph is right-to-left without an alignment: {xml}");
+}
+
+#[test]
+fn shape_text_without_rtl_writes_unchanged_ppr() {
+ let wb = read_drawing(&ARABIC_TEXT_BOX.replace(r#" rtl="1""#, ""));
+ assert!(!wb.sheet(0).unwrap().shapes[0].format.rtl);
+ let wb = read_drawing(&ARABIC_TEXT_BOX.replace(r#" rtl="1""#, r#" rtl="0""#));
+ assert!(!wb.sheet(0).unwrap().shapes[0].format.rtl);
+
+ let xml = written_drawing(&text_box("left\nto right", ShapeFormat { align: Some(ShapeTextAlign::Right), ..Default::default() }));
+ assert_eq!(xml.matches(r#""#).count(), 2, "{xml}");
+ assert!(!xml.contains("rtl=\"1\""), "{xml}");
+
+ let xml = written_drawing(&text_box("plain", ShapeFormat::default()));
+ assert!(!xml.contains(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..692d8d44c 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (text boxes and shapes keep right-to-left paragraph direction through XLSX save; previously minor: Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -31,7 +31,9 @@ right-to-left sheets and full complex-script support. Part of
(`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`) mirrors the grid (#146):
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box
- (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. IME input goes
+ (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.
+ Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX
+ save, though GridCraft still draws that text left to right. IME input goes
through egui/winit and is unverified for Chinese, Japanese and Korean.
- **PDF export** uses only the 14 standard PDF fonts: non-Latin text cannot export.
@@ -78,6 +80,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 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-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 59074fdc165b5e3fa81cccb2ca0fc5dd7d9167ed Mon Sep 17 00:00:00 2001
From: Mouhand-Kaddo
Date: Mon, 19 Oct 2026 15:33:05 +0400
Subject: [PATCH 35/46] M12.AR9: open right-to-left sheets from .xls files
right-to-left
---
crates/xlsx/src/tests/xls_tests.rs | 28 ++++++++++++++++++++++++++++
crates/xlsx/src/xls/mod.rs | 13 +++++++++++++
docs/file-format-parity.md | 5 +++--
docs/localization-parity.md | 5 +++--
4 files changed, 47 insertions(+), 4 deletions(-)
diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs
index 91e63c18f..c65d7577d 100644
--- a/crates/xlsx/src/tests/xls_tests.rs
+++ b/crates/xlsx/src/tests/xls_tests.rs
@@ -594,3 +594,31 @@ fn a_format_shorter_than_its_count_keeps_its_characters() {
assert_eq!(fmt("A3"), "0.000");
assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings);
}
+
+fn one_sheet_with(sheet_records: &[u8]) -> Vec {
+ let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &eof()]);
+ let sheet = cat(&[&bof(0x0010), sheet_records, &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]);
+ compound("Workbook", &cat(&[&globals(globals(0).len() as u32), &sheet]))
+}
+
+#[test]
+fn xls_window2_right_to_left_sheet_opens_right_to_left() {
+ let window2 = |flags: u16| rec(0x023E, &u16s(&[flags, 0, 0, 64, 0, 0, 0, 0, 0]));
+ let view = |file: &[u8]| {
+ let (wb, _) = read_xls(file).unwrap();
+ let sh = &wb.sheets[0];
+ (sh.right_to_left, sh.show_gridlines, sh.show_headings, sh.show_zeros, sh.show_formulas)
+ };
+ assert_eq!(view(&one_sheet_with(&window2(0x0040 | 0x0004))), (true, false, true, false, false));
+ assert_eq!(view(&one_sheet_with(&window2(0x0001 | 0x0002 | 0x0010))), (false, true, false, true, true));
+}
+
+#[test]
+fn xls_short_window2_is_ignored() {
+ for payload in [&[][..], &[0x40][..], &[0x40, 0][..], &[0x40, 0, 0, 0, 0, 0, 0, 0, 0][..]] {
+ let (wb, _) = read_xls(&one_sheet_with(&rec(0x023E, payload))).unwrap();
+ let sh = &wb.sheets[0];
+ assert_eq!((sh.right_to_left, sh.show_gridlines, sh.show_headings, sh.show_zeros), (false, true, true, true));
+ assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0));
+ }
+}
diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs
index dde8641e5..8e2c092c6 100644
--- a/crates/xlsx/src/xls/mod.rs
+++ b/crates/xlsx/src/xls/mod.rs
@@ -561,6 +561,9 @@ fn worksheet(
Ok(Some((st.sheet, st.calc)))
}
+/// The shortest WINDOW2 a file holds (the chart-sheet form); a shorter one is truncated.
+const WINDOW2_MIN_LEN: usize = 10;
+
fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> {
let mut c = Cursor::new(&rec.parts);
// A formula's string result is the String record right after it (shared/array records may sit between).
@@ -739,6 +742,16 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget,
st.sheet.default_row_height = pt_to_px(f64::from(height) / 20.0);
}
}
+ 0x023E => {
+ let flags = if c.remaining() >= WINDOW2_MIN_LEN { c.u16().ok() } else { None };
+ let Some(flags) = flags else { return Ok(()) };
+ let on = |bit: u16| flags & bit != 0;
+ st.sheet.show_formulas = on(0x0001);
+ st.sheet.show_gridlines = on(0x0002);
+ st.sheet.show_headings = on(0x0004);
+ st.sheet.show_zeros = on(0x0010);
+ st.sheet.right_to_left = on(0x0040);
+ }
0x001C => report.warn("Cell comments in .xls files are not imported yet."),
0x00EC => report.warn("Pictures, charts and shapes in .xls files are not imported yet."),
0x01B0 | 0x0879 => report.warn("Conditional formatting in .xls files is not imported yet."),
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 158149fb3..41881d700 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (`.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -29,7 +29,7 @@ and repair-free opening remain unverified.
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
| `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` |
-| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` |
+| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` |
| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — |
| `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — |
| `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — |
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..610af854e 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (right-to-left sheets in `.xls` files open right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -28,7 +28,7 @@ right-to-left sheets and full complex-script support. Part of
- **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32;
#142 is still open pending confirmation). There is no complex-text shaping: Arabic and
Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
- (`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`) mirrors the grid (#146):
+ (`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146):
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box
(no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. IME input goes
@@ -78,6 +78,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | Right-to-left sheets in legacy `.xls` files (common in older Arabic workbooks) open right-to-left |
| 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 a10ead1c09abe3f72698929dae47ce9d372d572d Mon Sep 17 00:00:00 2001
From: Mouhand-Kaddo
Date: Mon, 19 Oct 2026 15:59:46 +0400
Subject: [PATCH 36/46] M12.AR11: mark right-to-left sheets and Arabic text in
HTML export
---
crates/engine/src/io.rs | 177 +++++++++++++++++++++++++++++++++---
docs/file-format-parity.md | 3 +-
docs/localization-parity.md | 3 +-
3 files changed, 167 insertions(+), 16 deletions(-)
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index 3e84036cb..25edc8557 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -251,7 +251,7 @@ fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
-fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
+fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
let st = wb.styles.get(sh.style_id(c));
let mut css = String::new();
if st.font.bold {
@@ -270,16 +270,59 @@ fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
}
let v = sh.value(c);
let align = match st.align.h {
- gridcraft_model::HAlign::Center => "center",
- gridcraft_model::HAlign::Right => "right",
- gridcraft_model::HAlign::Left => "left",
- _ if v.is_number() => "right",
- _ => "left",
+ gridcraft_model::HAlign::Center => Some("center"),
+ gridcraft_model::HAlign::Right => Some("right"),
+ gridcraft_model::HAlign::Left => Some("left"),
+ _ if v.is_number() => Some(if sh.right_to_left { "left" } else { "right" }),
+ _ if dir == CellDir::Auto => None,
+ _ => Some("left"),
};
- let _ = write!(css, "text-align:{align};");
+ if let Some(align) = align {
+ let _ = write!(css, "text-align:{align};");
+ }
css
}
+/// How a cell marks its direction in HTML. Text on a right-to-left sheet, or text with
+/// right-to-left letters, follows its first strong character (`dir="auto"`), so General
+/// alignment puts Arabic and Hebrew on the right. Numbers on a right-to-left sheet stay left to
+/// right so `-5` doesn't read `5-`.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum CellDir {
+ Inherit,
+ Auto,
+ Ltr,
+}
+
+impl CellDir {
+ fn of(sh: &Sheet, c: CellRef, text: &str) -> CellDir {
+ if sh.value(c).is_number() {
+ if sh.right_to_left { CellDir::Ltr } else { CellDir::Inherit }
+ } else if sh.right_to_left || text.chars().any(is_right_to_left_letter) {
+ CellDir::Auto
+ } else {
+ CellDir::Inherit
+ }
+ }
+
+ fn attr(self) -> &'static str {
+ match self {
+ CellDir::Inherit => "",
+ CellDir::Auto => " dir=\"auto\"",
+ CellDir::Ltr => " dir=\"ltr\"",
+ }
+ }
+}
+
+/// Hebrew, Arabic, Syriac, Thaana, N'Ko and their presentation forms.
+fn is_right_to_left_letter(c: char) -> bool {
+ matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')
+}
+
+fn table_dir(sh: &Sheet) -> &'static str {
+ if sh.right_to_left { " dir=\"rtl\"" } else { "" }
+}
+
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
@@ -292,7 +335,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
}
let sh = wb.sheet(sheet)?;
let mut out = ClipboardHtml(String::new());
- out.write_str("").ok()?;
+ write!(out, "", table_dir(sh)).ok()?;
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
@@ -310,11 +353,12 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
} else {
String::new()
};
- let css = html_cell_css(wb, sh, c);
- write!(out, "| ").ok()?;
// Use the visible selected cell, even when the merge's original anchor
// is outside the range or hidden. This matches the plain-text copy.
let text = crate::display::cell_text(wb, sh, c);
+ let dir = CellDir::of(sh, c, &text);
+ let css = html_cell_css(wb, sh, c, dir);
+ write!(out, " | ", dir.attr()).ok()?;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
@@ -363,8 +407,9 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result {
let mut out = String::new();
let _ = write!(
out,
- "{}",
- esc(&sh.name)
+ "{}",
+ esc(&sh.name),
+ table_dir(sh)
);
if let Some(r) = sh.used_range() {
if r.height() > HTML_EXPORT_MAX_ROWS {
@@ -383,14 +428,16 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result {
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
continue;
}
- let css = html_cell_css(wb, sh, c);
+ let text = crate::display::cell_text(wb, sh, c);
+ let dir = CellDir::of(sh, c, &text);
+ let css = html_cell_css(wb, sh, c, dir);
let span = sh
.merges
.iter()
.find(|m| m.start == c)
.map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()))
.unwrap_or_default();
- let _ = write!(out, "| {} | ", esc(&crate::display::cell_text(wb, sh, c)));
+ let _ = write!(out, "{} | ", dir.attr(), esc(&text));
}
out.push_str("");
}
@@ -500,6 +547,108 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
+ fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook {
+ let mut wb = Workbook::new();
+ let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect();
+ let sh = wb.sheet_mut(0).unwrap();
+ sh.right_to_left = rtl;
+ for ((at, value, _), id) in cells.iter().zip(ids) {
+ let c = CellRef::parse(at).unwrap();
+ sh.set_value(c, value.clone());
+ sh.set_style(c, id);
+ }
+ wb
+ }
+
+ fn copied(wb: &Workbook, range: &str) -> String {
+ let r = RangeRef::parse(range).unwrap();
+ range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
+ }
+
+ #[test]
+ fn html_export_marks_right_to_left_sheets() {
+ use gridcraft_core::Value;
+ use gridcraft_model::HAlign;
+ let wb = book(true, &[("A1", Value::text("Hello!"), HAlign::General), ("B1", Value::Number(-5.0), HAlign::General)]);
+ let html = to_html(&wb, 0).unwrap();
+ assert!(html.contains(""), "{html}");
+ assert!(html.contains("| Hello! | "), "Latin text keeps its own direction: {html}");
+ assert!(
+ html.contains("-5 | "),
+ "numbers mirror to the left and keep their sign in front: {html}"
+ );
+ let clip = copied(&wb, "A1:B1");
+ assert!(clip.starts_with("-5"), "{clip}");
+
+ let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]);
+ assert!(to_html(<r, 0).unwrap().contains(""));
+ assert!(copied(<r, "A1").starts_with("مرحبا!"), "General Arabic text aligns by its direction: {html}");
+ assert!(html.contains("| שלום | "), "explicit alignment stays: {html}");
+ assert!(html.contains("مرحبا | "), "{html}");
+ assert!(html.contains("Hello | "), "{html}");
+ assert!(html.contains(""), "a left-to-right sheet has no table direction: {html}");
+
+ let clip = copied(&wb, "A1:D1");
+ let cells: Vec<&str> = clip.split(""), HAlign::General),
+ ("B2", Value::Number(3.0), HAlign::General),
+ ("A3", Value::text("Total"), HAlign::Right),
+ ],
+ );
+ wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap());
+ assert_eq!(
+ to_html(&wb, 0).unwrap(),
+ "Sheet1\
+ | Name | Qty | \
+ | Apples & <pears> | 3 | \
+ | Total | \
+ "
+ );
+ let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;";
+ assert_eq!(
+ copied(&wb, "A1:B2"),
+ format!(
+ "\
+ | Name | Qty | \
+ | Apples & <pears> | 3 | "
+ )
+ );
+ }
+
#[test]
fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() {
let mut wb = Workbook::new();
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 158149fb3..3684e57df 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..35a476e92 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Save as Web Page and copied HTML keep right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -78,6 +78,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 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-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 e9ffc71c6aed505ad27f0fe241c7433bf7a71f54 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:27:46 +0000
Subject: [PATCH 37/46] Search from the end for negative TEXTBEFORE/TEXTAFTER
instances
Found while testing crates/functions/src/text.rs:511-552
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Opus 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/functions/src/text.rs | 33 ++++++++++++++++++++++++++++-----
1 file changed, 28 insertions(+), 5 deletions(-)
diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs
index 91962d6e7..621c9b53b 100644
--- a/crates/functions/src/text.rs
+++ b/crates/functions/src/text.rs
@@ -505,16 +505,33 @@ fn delimiters(v: &Value) -> R>> {
as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect()
}
-/// Non-overlapping delimiter matches (start, end) in char positions, scanning left to right and
-/// preferring the longest delimiter at each position. Empty delimiters are ignored unless all
+/// Non-overlapping delimiter matches (start, end) in char positions, in ascending order. Scans left
+/// to right preferring the longest delimiter starting at each position, or (`from_end`) right to
+/// left preferring the longest delimiter ending at each position. Empty delimiters are ignored unless all
/// delimiters are empty, in which case every position matches with zero length.
-fn find_matches(text: &[char], delims: &[Vec], insensitive: bool) -> Vec<(usize, usize)> {
+fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end: bool) -> Vec<(usize, usize)> {
let t = if insensitive { fold(text) } else { text.to_vec() };
let ds: Vec> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect();
if ds.is_empty() {
return (0..=t.len()).map(|i| (i, i)).collect();
}
let mut out = Vec::new();
+ if from_end {
+ let mut j = t.len();
+ while j > 0 {
+ let best =
+ ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
+ match best {
+ Some(l) => {
+ out.push((j - l, j));
+ j -= l;
+ }
+ None => j -= 1,
+ }
+ }
+ out.reverse();
+ return out;
+ }
let mut i = 0;
while i < t.len() {
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
@@ -538,7 +555,7 @@ fn text_before_after(a: &[Arg], before: bool) -> R {
if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) {
return Err(CellError::Value);
}
- let mut m = find_matches(&s, &delims, mode == 1);
+ let mut m = find_matches(&s, &delims, mode == 1, inst < 0);
if match_end {
if inst > 0 {
if m.last().is_none_or(|x| x.0 != s.len()) {
@@ -573,7 +590,7 @@ fn split_by(s: &[char], delims: &[Vec], insensitive: bool) -> Vec
Date: Mon, 19 Oct 2026 17:04:06 +0400
Subject: [PATCH 38/46] M12.AR16: limit table names to 255 characters
---
crates/engine/src/cmd/insert.rs | 7 ++++---
crates/engine/src/tests.rs | 20 ++++++++++++++++++++
docs/localization-parity.md | 3 ++-
3 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs
index d878e4469..9f044613d 100644
--- a/crates/engine/src/cmd/insert.rs
+++ b/crates/engine/src/cmd/insert.rs
@@ -466,10 +466,11 @@ fn table_style(s: &mut Session, p: &Json) -> Result {
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
-/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
-/// structured reference, and the writer would rename the table on save.
+/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
+/// read as an operator inside a structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
- name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
+ name.chars().count() <= 255
+ && name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ae499a189..e38bd239a 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1206,6 +1206,26 @@ fn table_rename_keeps_a_defined_name_with_the_same_name() {
assert_eq!((v(&s, "E1"), v(&s, "F1")), (Value::Number(100.0), Value::Number(7.0)));
}
+#[test]
+fn table_names_are_limited_to_255_characters() {
+ let mut s = s();
+ s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
+ s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Original"})).unwrap();
+
+ let name_255 = "ع".repeat(255);
+ s.execute("table.rename", json!({"table": "Original", "name": name_255})).unwrap();
+ assert!(s.doc().unwrap().wb.table(&"ع".repeat(255)).is_some());
+
+ let name_256 = "ع".repeat(256);
+ let error = s.execute("table.rename", json!({"table": &"ع".repeat(255), "name": name_256})).unwrap_err();
+ assert!(error.to_string().contains("not valid"), "{error}");
+ assert!(s.doc().unwrap().wb.table(&"ع".repeat(255)).is_some());
+
+ let name_200 = "ع".repeat(200);
+ s.execute("table.rename", json!({"table": &"ع".repeat(255), "name": name_200})).unwrap();
+ assert!(s.doc().unwrap().wb.table(&"ع".repeat(200)).is_some());
+}
+
#[test]
fn slicer_filters_table() {
let mut s = s();
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..6be0119aa 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Table names are limited to 255 characters) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -78,6 +78,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | Table names are limited to 255 Unicode characters |
| 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 7cd0a386aa4af37aaca234ad5b15a45e70164627 Mon Sep 17 00:00:00 2001
From: Mouhand-Kaddo
Date: Mon, 19 Oct 2026 17:29:40 +0400
Subject: [PATCH 39/46] M12.AR17: skip .xls dialog sheets instead of reading
them as worksheets
---
crates/xlsx/src/tests/xls_tests.rs | 14 ++++++++++++++
crates/xlsx/src/xls/mod.rs | 4 ++++
docs/file-format-parity.md | 3 ++-
3 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs
index 91e63c18f..cf99a9447 100644
--- a/crates/xlsx/src/tests/xls_tests.rs
+++ b/crates/xlsx/src/tests/xls_tests.rs
@@ -454,6 +454,20 @@ fn hostile_xls_input_never_panics_or_hangs() {
}
}
+#[test]
+fn dialog_sheets_are_skipped_not_read_as_worksheets() {
+ let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
+ let globals = |positions: [u32; 2]| cat(&[&bof(0x0005), &boundsheet(positions[0], "Dialog1"), &boundsheet(positions[1], "Sheet1"), &eof()]);
+ let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &1.0f64.to_le_bytes()])), &eof()]);
+ let worksheet = cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]);
+ let first = globals([0; 2]).len() as u32;
+ let stream = cat(&[&globals([first, first + dialog.len() as u32]), &dialog, &worksheet]);
+ let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap();
+ assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1"]);
+ assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0));
+ assert!(report.warnings.iter().any(|w| w.contains("Dialog sheets")), "{:?}", report.warnings);
+}
+
#[test]
fn sheets_share_one_record_budget() {
// Many BoundSheet records point into one long substream (at successive nested BOFs), so each
diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs
index dde8641e5..7a5e6e4b3 100644
--- a/crates/xlsx/src/xls/mod.rs
+++ b/crates/xlsx/src/xls/mod.rs
@@ -40,6 +40,9 @@ const MAX_RECORDS: usize = 40_000_000;
const BOF: u16 = 0x0809;
const EOF: u16 = 0x000A;
const BIFF8: u16 = 0x0600;
+/// WsBool (MS-XLS 2.4.351): its first byte's `fDialog` bit marks a dialog sheet, whose BOF says worksheet.
+const WSBOOL: u16 = 0x0081;
+const WSBOOL_DIALOG: u8 = 0x10;
pub(crate) fn invalid(msg: impl Into) -> IoError {
IoError::Format(format!("XLS: {}", msg.into()))
@@ -524,6 +527,7 @@ fn worksheet(
continue;
}
_ if depth > 1 => continue,
+ WSBOOL if rec.parts.first().and_then(|p| p.first()).is_some_and(|b| b & WSBOOL_DIALOG != 0) => return Ok(None),
_ => {}
}
sheet_record(&rec, &mut st, budget, report)?;
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 158149fb3..ab60b59a9 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
From 5eae222fbc508958f4b84afdc966eb9dbd3c96dd Mon Sep 17 00:00:00 2001
From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com>
Date: Mon, 19 Oct 2026 18:21:54 +0300
Subject: [PATCH 40/46] Outline: + shows zero-width hidden columns at the
default width (fixes #1714)
Templates hide grouped columns as width="0" hidden="1". Show Detail and the level buttons only cleared hidden, so the columns stayed invisible; they now reset a zero size, as Unhide does.
---
crates/engine/src/cmd/data.rs | 39 +++++++++++++++++++++++++++++++++--
1 file changed, 37 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs
index 383624b70..28e4060fe 100644
--- a/crates/engine/src/cmd/data.rs
+++ b/crates/engine/src/cmd/data.rs
@@ -1199,7 +1199,7 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result {
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
- e.hidden = hide || inner.iter().any(|o| o.contains(i));
+ set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if hide {
@@ -1213,6 +1213,16 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result {
})
}
+/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as
+/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the
+/// group expanded and leave it invisible (#1714).
+fn set_hidden(e: &mut LineInfo, hidden: bool) {
+ e.hidden = hidden;
+ if !hidden && e.size == Some(0.0) {
+ e.size = None;
+ }
+}
+
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
fn show_levels(s: &mut Session, p: &Json) -> Result {
@@ -1231,7 +1241,7 @@ fn show_levels(s: &mut Session, p: &Json) -> Result {
let Some(n) = n else { continue };
let groups = outline::groups(map);
for e in map.values_mut().filter(|e| e.outline > 0) {
- e.hidden = e.outline >= n;
+ set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
@@ -1367,6 +1377,31 @@ mod tests {
assert!(!ok(&mut s, "B1", "150"));
}
+ #[test]
+ fn showing_detail_gives_zero_width_columns_their_width_back() {
+ // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them.
+ let mut s = Session::new();
+ s.new_workbook();
+ let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() };
+ let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
+ for c in 2..=4 {
+ sh.cols.insert(c, hidden);
+ }
+ sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() });
+ s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap();
+ let sh = s.doc().unwrap().wb.active().unwrap();
+ for c in 2..=4 {
+ assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}");
+ }
+ // Hiding again hides them; the level buttons show zero-width columns too.
+ s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap();
+ assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0);
+ Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden);
+ s.execute("data.showLevels", json!({"cols": 2})).unwrap();
+ let sh = s.doc().unwrap().wb.active().unwrap();
+ assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width));
+ }
+
#[test]
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
let mut s = Session::new();
From f2473d1f337f1cf2ea50a96c7330bb2338263f46 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 12:20:46 -0400
Subject: [PATCH 41/46] Ctrl+Shift+= on several whole-row areas still opens the
Insert dialog
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/ribbon.rs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
index d7c6bd697..662c87ef1 100644
--- a/crates/ui-egui/src/ribbon.rs
+++ b/crates/ui-egui/src/ribbon.rs
@@ -2462,7 +2462,8 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
None
}
},
- Key::Plus | Key::Equals if shift => match whole_lines(app) {
+ // Insert works on one area at a time, so several areas still go through the dialog.
+ Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) {
Some(true) => Some(("home.insertRows", json!({}))),
Some(false) => Some(("home.insertColumns", json!({}))),
None => {
@@ -2525,6 +2526,11 @@ mod checkbox_on_tests {
}
}
+/// Whether the selection is a single area.
+fn single_area(app: &SheetApp) -> bool {
+ app.session.active().is_some_and(|d| d.selection.ranges.len() == 1)
+}
+
/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole
/// columns, `None` otherwise (including the whole sheet).
fn whole_lines(app: &SheetApp) -> Option {
From 8968e833592c4feedd7ac2fda6e777c3926758ed Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 12:21:01 -0400
Subject: [PATCH 42/46] RangeRef::parse: whole columns take at most one $ too
Co-Authored-By: Claude Opus 5.5
---
crates/core/src/addr.rs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs
index 4780158e8..2c087f22e 100644
--- a/crates/core/src/addr.rs
+++ b/crates/core/src/addr.rs
@@ -210,7 +210,7 @@ impl RangeRef {
if let (Some(x), Some(y)) = (CellRef::parse(a), CellRef::parse(b)) {
return Some(RangeRef::new(x, y));
}
- let strip = |t: &str| t.trim_start_matches('$').to_string();
+ let strip = |t: &str| t.strip_prefix('$').unwrap_or(t).to_string();
if let (Some(x), Some(y)) = (letters_to_col(&strip(a)), letters_to_col(&strip(b))) {
return Some(RangeRef::cols(x.min(y), x.max(y)));
}
@@ -247,6 +247,8 @@ mod tests {
assert!(RangeRef::parse("-1:3").is_none());
assert!(RangeRef::parse("$$1:3").is_none());
assert!(RangeRef::parse(":3").is_none());
+ assert!(RangeRef::parse("$$A:$$C").is_none());
+ assert!(RangeRef::parse("$A:$C").is_some());
let r = RangeRef::parse("$1:$3").unwrap();
assert_eq!((r.start.row, r.end.row), (0, 2));
}
From 33ac9e5f64a35745e56d69eccd35c33db1621e43 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 12:21:13 -0400
Subject: [PATCH 43/46] Drop the dbus-run-session test: it spawns a bus, sleeps
9 s and fails where dbus-daemon is missing
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/desktop_theme.rs | 30 -----------------------------
1 file changed, 30 deletions(-)
diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs
index 7b836d2a4..9d67c337a 100644
--- a/crates/ui-egui/src/desktop_theme.rs
+++ b/crates/ui-egui/src/desktop_theme.rs
@@ -140,36 +140,6 @@ mod tests {
assert_eq!(prefers_dark(), None);
}
- /// The watcher keeps one session-bus connection instead of opening one per poll. Runs the
- /// check in a child process under a private `dbus-run-session` bus, so bus names are ours.
- #[test]
- #[cfg(target_os = "linux")]
- fn the_watcher_reuses_one_bus_connection() {
- const INNER: &str = "GRIDCRAFT_THEME_INNER";
- let unique = |c: &zbus::blocking::Connection| {
- c.unique_name().map(|n| n.as_str().rsplit('.').next().and_then(|s| s.parse::().ok()).unwrap_or(0)).unwrap_or(0)
- };
- if std::env::var_os(INNER).is_none() {
- let exe = std::env::current_exe().expect("cannot locate the test executable to re-run it under dbus-run-session");
- let out = std::process::Command::new("dbus-run-session")
- .arg("--")
- .arg(exe)
- .args(["--exact", "desktop_theme::tests::the_watcher_reuses_one_bus_connection", "--nocapture"])
- .env(INNER, "1")
- .output()
- .expect("dbus-run-session is required to run this test (install dbus-daemon / dbus-x11)");
- assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
- return;
- }
- let probe = zbus::blocking::Connection::session().expect("no session bus under dbus-run-session");
- let before = unique(&probe);
- let _watcher = DesktopTheme::start();
- std::thread::sleep(POLL * 2 + Duration::from_secs(1));
- let after_probe = zbus::blocking::Connection::session().expect("no session bus under dbus-run-session");
- let made = unique(&after_probe) - before - 1;
- assert!(made <= 1, "the watcher opened {made} bus connections in three reads");
- }
-
/// A watcher that was never started is inert rather than a panic.
#[test]
fn a_watcher_without_a_thread_is_inert() {
From f5267cb74e57874541985dcb29bda92fc653591b Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 12:22:25 -0400
Subject: [PATCH 44/46] docs: one Last updated line per parity doc after
merging
Co-Authored-By: Claude Opus 5.5
---
docs/file-format-parity.md | 6 +-----
docs/localization-parity.md | 5 +----
docs/ui-parity.md | 3 +--
3 files changed, 3 insertions(+), 11 deletions(-)
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 14ad1d332..0082c6200 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,10 +1,6 @@
# File-format parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (`.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 909c90646..610028b64 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,9 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Table names are limited to 255 characters) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (right-to-left sheets in `.xls` files open right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (text boxes and shapes keep right-to-left paragraph direction through XLSX save; previously minor: Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 8377ad16f..34cb502f5 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,7 +1,6 @@
# UI parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **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
From 27f4c9c7bc73a8acc33967bae52c677addd80d5e Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 12:22:57 -0400
Subject: [PATCH 45/46] Touch pan: a pan that never saw its release doesn't
swallow the next press
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/grid.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 6f5c5246f..4baf1c06a 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -769,6 +769,10 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
// Interaction.
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
+ // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press.
+ if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() {
+ app.grid.touch_pan = false;
+ }
if app.grid.touch_pan {
// Skip the frames of a touch pan, its release included.
if !resp.dragged() {
From 92c0d1e936e02563d1e45a033ac8080b45f951be Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 12:23:08 -0400
Subject: [PATCH 46/46] Chrome: turning the File tab off closes a Backstage
that is already open
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/lib.rs | 4 ++++
crates/ui-egui/tests/host_chrome.rs | 11 +++++++++++
2 files changed, 15 insertions(+)
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 3c6ed2b02..9adea9a08 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -1129,6 +1129,10 @@ 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);
+ // A host can turn the File tab off while Backstage is already open: close it.
+ if !self.chrome.file_tab {
+ self.backstage = None;
+ }
if self.backstage.is_none() {
self.ribbon_keys(&ctx);
}
diff --git a/crates/ui-egui/tests/host_chrome.rs b/crates/ui-egui/tests/host_chrome.rs
index 0773f4c49..627e24025 100644
--- a/crates/ui-egui/tests/host_chrome.rs
+++ b/crates/ui-egui/tests/host_chrome.rs
@@ -52,3 +52,14 @@ fn without_the_file_tab_the_backstage_view_never_opens() {
assert!(h.state().backstage.is_none(), "so is every page, Print and Save As included");
assert!(h.query_by_label("AutoSave").is_some(), "the title bar can stay on its own");
}
+
+#[test]
+fn turning_the_file_tab_off_closes_an_open_backstage() {
+ let mut h = harness(Chrome::default());
+ h.state_mut().open_backstage(Page::Home);
+ h.run_steps(3);
+ assert!(h.state().backstage.is_some(), "Backstage opens with the File tab on");
+ h.state_mut().chrome.file_tab = false;
+ h.run_steps(3);
+ assert!(h.state().backstage.is_none(), "and closes when the host turns the File tab off");
+}
|
|