diff --git a/ROADMAP.md b/ROADMAP.md index 01f9362d8..b78eeed77 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** minor (undo steps share picture and macro bytes; floating pictures' textures bounded and kept per workbook) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | +| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) | | 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB | | 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes | | 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | @@ -204,6 +206,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | +| 2026-10-19 | minor | Progress log: long flat operator chains (#916) | | 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes | | 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook | | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 812765249..b46f4d217 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -256,7 +256,7 @@ impl<'h> Evaluator<'h> { } fn name(&mut self, n: &str) -> Ev { - if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) { + if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)) { return v.clone(); } let wb = self.host.workbook(); @@ -772,7 +772,7 @@ impl<'h> Evaluator<'h> { Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) } "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), @@ -975,7 +975,7 @@ impl<'h> Evaluator<'h> { /// that name. fn function_by_name(&self, e: &Expr) -> Option { let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { return None; } let u = n.to_ascii_uppercase(); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 6dd57e334..4e5486fbe 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -590,6 +590,27 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn let_lambda_names_ignore_case_in_any_script() { + let mut t = T::new(); + t.set("A1", "=LET(dług,100,DŁUG*2)"); + assert_eq!(t.num("A1"), 200.0); + t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()"); + assert_eq!(t.get("A2"), Value::Bool(true)); + t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)"); + assert_eq!(t.num("A3"), 2.0); + t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))"); + assert_eq!(t.num("A4"), 6.0); + t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))"); + assert_eq!(t.num("A5"), 5.0); + // `ß` upper-cases to `SS`, yet `ẞ` is its capital. + t.set("A6", "=LET(ß,100,ẞ*2)"); + assert_eq!(t.num("A6"), 200.0); + // Dotless ı is a different name from i. + t.set("A7", "=LET(i,1,ı,2,i)"); + assert_eq!(t.num("A7"), 1.0); +} + #[test] fn functions_given_by_name() { let mut t = T::new(); @@ -720,6 +741,41 @@ fn long_operator_chains() { assert_eq!(t.num("B2"), 5.0); } +#[test] +fn flat_chains_as_long_as_excel_allows() { + // Values observed in Excel (#916): a flat chain has no operand limit below the 8,192-character + // formula limit, which `=1+1+…` with 4,096 operands meets exactly. On a 1 MiB stack, as on + // the Windows main thread. + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let chain = |op: &str, item: &str, n: usize| format!("={}", vec![item; n].join(op)); + let mut t = T::new(); + for r in 1..=1500 { + t.set(&format!("A{r}"), "1"); + } + t.set("B1", &chain("+", "1", 401)); + t.set("B2", &chain("+", "1", 4096)); + t.set("B3", &chain("-", "1", 2000)); + t.set("B4", &chain("*", "1", 4096)); + t.set("B5", &chain("/", "2", 4096)); + t.set("B6", &chain("^", "1", 4096)); + t.set("B7", &chain("+", "1%", 2000)); + t.set("B8", "=1=1=1"); + t.set("B9", &format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::>().join("+"))); + t.set("B10", &chain("&", "1", 2000)); + for (at, want) in [("B1", 401.0), ("B2", 4096.0), ("B3", -1998.0), ("B4", 1.0), ("B5", 0.0), ("B6", 1.0), ("B9", 1500.0)] { + assert_eq!(t.num(at), want, "{at}"); + } + assert!((t.num("B7") - 20.0).abs() < 1e-9); + assert_eq!(t.get("B8"), Value::Bool(false)); + assert_eq!(t.get("B10"), Value::text("1".repeat(2000))); + }) + .expect("spawn") + .join() + .expect("long chains must not overflow the stack"); +} + #[test] fn subtotal_skips_hidden() { let mut t = T::new(); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..de6c88258 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -49,14 +49,34 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { parse_numeric(s.trim(), sys).map(|(n, _)| n) } +/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs, +/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and +/// fractions kept as text are not flagged, as in Excel. +pub fn number_stored_as_text(s: &str) -> Option { + let t = s.trim(); + match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n), + None => parse_plain(t).map(|(n, _)| n), + } +} + fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { + // Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a + // loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180). + let mut t = t; + let mut neg = false; + while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + t = inner.trim(); + neg = !neg; + } + let (n, f) = parse_unparenthesised(t, sys)?; + Some((if neg { -n } else { n }, f)) +} + +fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { if t.is_empty() { return None; } - // Parenthesised negatives: (100) = -100. - if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { - return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f)); - } if let Some(r) = parse_plain(t) { return Some(r); } @@ -352,6 +372,24 @@ mod tests { assert_eq!(p(".5").value, Value::Number(0.5)); } + #[test] + fn deeply_nested_parentheses_do_not_overflow() { + // #2180: coercion used to recurse once per parenthesis and overflow the stack. + let depth = 100_000; + let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth)); + assert_eq!(parse_number_text(&s), Some(1.0)); + let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1)); + assert_eq!(parse_number_text(&odd), Some(-1.0)); + let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1)); + assert_eq!(parse_number_text(&unbalanced), None); + assert_eq!(p(&s).value, Value::Number(1.0)); + // Existing results are unchanged. + assert_eq!(parse_number_text("(100)"), Some(-100.0)); + assert_eq!(parse_number_text("((100))"), Some(100.0)); + assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5)); + assert_eq!(parse_number_text("()"), None); + } + #[test] fn texts_and_specials() { assert_eq!(p("hello").value, Value::text("hello")); @@ -398,4 +436,15 @@ mod tests { assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900")); assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); } + + #[test] + fn numbers_stored_as_text() { + for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] { + assert_eq!(number_stored_as_text(text), Some(n), "{text}"); + } + // Dates, times, fractions, words and blanks are not flagged. + for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] { + assert_eq!(number_stored_as_text(text), None, "{text}"); + } + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9e1f35754..3d8b79dab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -183,15 +183,8 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri /// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, /// and refuses a formula that still doesn't parse. pub(crate) fn parse_typed_formula(body: &str) -> Result { - let mut text = body.to_string(); - let mut parsed = gridcraft_formula::parse(&text); - for _ in 0..8 { - if parsed.is_ok() { - break; - } - text.push(')'); - parsed = gridcraft_formula::parse(&text); - } + // Excel closes missing parentheses for you. + let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5e7fcf008..52049528f 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -49,8 +49,19 @@ pub fn specs() -> Vec { ), cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents), cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), + cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents), + cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents), cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()), cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), + cmd!( + "formulas.convertToNumber", + "Convert to Number", + [], + None, + "{range?} → {converted} (numbers stored as text in the range or selection become numbers)", + has_doc, + convert_to_number + ), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula), cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate), @@ -482,6 +493,82 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { Ok(Json::Array(v)) } +/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on +/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]). +fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result { + use std::collections::BTreeSet; + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let sheet = d.wb.active_sheet; + let all = bool_param(p, "all").unwrap_or(false); + // (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells. + let mut found: BTreeSet<(usize, String)> = BTreeSet::new(); + let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new(); + let mut frontier = vec![(sheet, at)]; + seen.insert((sheet, at)); + while let Some((ks, kc)) = frontier.pop() { + if precedents { + for a in d.calc.graph.precedents_of((ks, kc)) { + if all { + // Walk only the stored cells inside the precedent area (sparse + // range iterator, not the whole sheet): blanks are never selected + // and only formula cells extend the frontier, so a formula late in + // a large range is not skipped. + let Some(sh) = d.wb.sheet(a.sheet) else { continue }; + for (c, cell) in sh.cells.iter_range(a.range) { + if matches!(cell.value, Value::Empty) { + continue; + } + if seen.insert((a.sheet, c)) { + found.insert((a.sheet, c.a1())); + if cell.formula.is_some() { + frontier.push((a.sheet, c)); + } + } + } + } else { + found.insert((a.sheet, a.range.a1())); + } + } + } else { + let mut deps = Vec::new(); + d.calc.graph.dependents(ks, kc, &mut deps); + for k2 in deps { + if all { + if seen.insert(k2) { + found.insert((k2.0, k2.1.a1())); + frontier.push(k2); + } + } else { + found.insert((k2.0, k2.1.a1())); + } + } + } + if !all { + break; + } + } + found.remove(&(sheet, at.a1())); + let name = if precedents { "precedent" } else { "dependent" }; + let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else { + return Err(bad("cell", format!("No {name} cells."))); + }; + let list: Vec = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect(); + let mut params = json!({ "range": list.join(",") }); + if si != sheet { + params["sheet"] = json!(si); + } + s.execute("selection.set", params) +} + +fn select_precedents(s: &mut Session, p: &Json) -> Result { + select_trace_cells(s, p, true) +} + +fn select_dependents(s: &mut Session, p: &Json) -> Result { + select_trace_cells(s, p, false) +} + fn show_formulas(s: &mut Session, p: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet; let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas); @@ -492,6 +579,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result { }) } +/// The number a cell's text stands for when Excel's background check flags it as a number stored +/// as text: a constant (not a formula's result) that would be a number if typed. +pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option { + match &cell.value { + Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t), + _ => None, + } +} + +/// Convert to Number: each number stored as text in the range becomes that number, keeping the +/// cell's format. +fn convert_to_number(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + edit(s, |cx| { + let mut converted = 0; + for r in &ranges { + let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue }; + let found: Vec<(CellRef, f64)> = cx + .wb + .sheet(sheet) + .map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect()) + .unwrap_or_default(); + let sh = cx.sheet_mut(sheet)?; + for (c, n) in &found { + sh.set_value(*c, Value::number(*n)); + } + cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c))); + converted += found.len(); + } + Ok(json!({"converted": converted})) + }) +} + fn error_checking(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; let mut out = Vec::new(); @@ -531,13 +652,15 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { let mut steps = Vec::new(); let mut nodes = Vec::new(); expr.walk(&mut |e| { + // Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the + // rest of the chain each time (quadratic memory). if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) { - nodes.push(e.clone()); + nodes.push(e); } }); for e in nodes.into_iter().rev().take(200) { - let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e); - steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)})); + let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e); + steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)})); } let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr); Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)})) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 58430d0dd..c4bc009ae 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Page Setup…", ["Page Layout", "Page Setup"], None, - "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings", + "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings", has_doc, page_setup ), @@ -71,19 +71,36 @@ pub fn paper_size(name: &str) -> (f32, f32) { known_paper(name).unwrap_or((612.0, 792.0)) } +/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its +/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as +/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too. fn known_paper(name: &str) -> Option<(f32, f32)> { - let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], ""); + // Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5` + // are one name. + let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); Some(match n.as_str() { "letter" | "usletter" => (612.0, 792.0), "legal" | "uslegal" => (612.0, 1008.0), "a4" => (595.28, 841.89), "a3" => (841.89, 1190.55), "a5" => (419.53, 595.28), + "a6" => (297.64, 419.53), "tabloid" | "11x17" => (792.0, 1224.0), "ledger" => (1224.0, 792.0), "executive" => (522.0, 756.0), - "b5" => (498.9, 708.66), - "b4" => (708.66, 1000.63), + "b4" | "b4jis" => (728.50, 1031.81), + "b5" | "b5jis" => (515.91, 728.50), + "b6" | "b6jis" => (362.83, 515.91), + "b4iso" => (708.66, 1000.63), + "b5iso" => (498.90, 708.66), + "b6iso" => (354.33, 498.90), + "japanesepostcard" | "postcard" => (283.46, 419.53), + "japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93), + "japaneseenvelope#3" | "chou#3" => (340.16, 666.14), + // 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out. + "japaneseenvelope#4" | "chou#4" => (255.12, 581.10), + "japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10), + "japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20), _ => return None, }) } @@ -2057,6 +2074,81 @@ mod tests { assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some()); } + /// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the + /// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too. + #[test] + fn japanese_paper_sizes_are_jis() { + for (name, w, h) in [ + ("A4", 595.28, 841.89), // unchanged + ("B4", 728.50, 1031.81), + ("B5", 515.91, 728.50), + ("B6", 362.83, 515.91), + ("B4 (JIS)", 728.50, 1031.81), + ("b5-jis", 515.91, 728.50), + ("B4 (ISO)", 708.66, 1000.63), + ("B5 (ISO)", 498.90, 708.66), + ("B6 (ISO)", 354.33, 498.90), + ("A6", 297.64, 419.53), + ("Japanese Postcard", 283.46, 419.53), + ("japanese double postcard", 419.53, 566.93), + ("Japanese Envelope #3", 340.16, 666.14), + ("chou #4", 255.12, 581.10), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ("kaku #3", 612.28, 785.20), + ] { + let got = paper_size(name); + assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); + } + // 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the + // 225 mm it once had. + let chou4 = paper_size("Japanese Envelope #4"); + assert_eq!((chou4.0, chou4.1), (255.12, 581.10)); + assert_eq!(chou4, paper_size("chou #4")); + // Unknown names keep the old Letter fallback. + assert_eq!(paper_size("Napkin"), (612.0, 792.0)); + } + + /// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is + /// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and + /// fit-to-page keeps the paper size. + #[test] + fn page_setup_paper_sizes_reach_the_pdf_page() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap(); + let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01; + for (paper, w, h) in [ + ("A4", 595.28f64, 841.89f64), + ("B5", 515.91, 728.50), + ("Japanese Postcard", 283.46, 419.53), + ("Japanese Double Postcard", 419.53, 566.93), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ] { + s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + let page = &pv["pageList"][0]; + assert_eq!(page["paper"], paper); + assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + let want = format!("/MediaBox [0 0 {w} {h}]"); + assert!(contains(&pdf, &want), "{paper}: wanted {want}"); + } + // Landscape swaps the sides, and fit-to-page does not change the paper. + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]")); + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true})); + assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]")); + // `pageLayout.size` still takes any name, and an unknown one prints on Letter as before. + s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0)); + // Page Setup refuses an unknown name. + assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); + } + #[test] fn hostile_settings_never_panic_and_cap_pages() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b76d8f70a..19a3e6d3f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -45,7 +45,15 @@ pub fn specs() -> Vec { }), cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection), cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation), - cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper), + cmd!( + "pageLayout.size", + "Size", + ["Page Layout", "Page Setup"], + None, + "{paper: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes)}", + has_doc, + paper + ), cmd!( "pageLayout.margins", "Margins", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5e43016f1..98767f4fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -259,6 +259,21 @@ fn evaluate_refuses_what_entry_refuses_and_says_why() { assert_eq!(eval(&mut s, "1/4").unwrap(), json!(0.25)); } +#[test] +fn an_incomplete_formula_is_refused_with_an_error_about_what_was_typed() { + // #2030: `=1+` said "unexpected `)`", although no `)` was typed: entry closes missing + // parentheses and reported the error of its last retry. + let mut s = s(); + for (input, msg) in [("=1+", "unexpected end of formula"), ("=SUM(1,+", "unexpected end of formula")] { + let e = s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap_err().to_string(); + assert!(e.contains("problem with this formula") && e.contains(msg), "{input}: {e}"); + assert!(!e.contains('`'), "{input}: {e}"); + } + // Missing closing parentheses are still added. + s.execute("cell.set", json!({"cell": "A1", "input": "=SUM(1,(2+3"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(6.0)); +} + #[test] fn undo_redo() { let mut s = s(); @@ -3765,3 +3780,32 @@ fn a_protected_sheet_allows_sorting_and_filtering_only_when_it_says_so() { assert!(s.execute("data.sortAscending", json!({})).is_err(), "locked cells cannot be sorted"); assert_eq!(order(&s), (Value::Number(2.0), Value::Number(1.0))); } + +#[test] +fn convert_to_number_turns_numbers_stored_as_text_into_numbers() { + let mut s = s(); + let value = |s: &mut Session, cell: &str| s.execute("cell.get", json!({"cell": cell})).unwrap()["value"].clone(); + for (cell, input) in [("A1", "'123"), ("A2", "'1,000.5"), ("A3", "'abc"), ("A4", "'3/4/2026"), ("A5", "=\"7\"")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); + let r = s.execute("formulas.convertToNumber", json!({"range": "A1:A5"})).unwrap(); + assert_eq!(r["converted"], 2); + assert_eq!(value(&mut s, "A1"), json!(123.0)); + assert_eq!(value(&mut s, "A2"), json!(1000.5)); + // Words, date text and a formula's text result stay as they are. + assert_eq!(value(&mut s, "A3"), json!("abc")); + assert_eq!(value(&mut s, "A4"), json!("3/4/2026")); + assert_eq!(value(&mut s, "A5"), json!("7")); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["font"]["bold"], true, "the format is kept"); + // SUM now counts them; undo brings the text back in one step. + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap(); + assert_eq!(value(&mut s, "B1"), json!(1123.5)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(value(&mut s, "A1"), json!("123")); + // With no range it works on the selection. + s.execute("selection.set", json!({"range": "A2"})).unwrap(); + assert_eq!(s.execute("formulas.convertToNumber", json!({})).unwrap()["converted"], 1); + assert_eq!(value(&mut s, "A1"), json!("123")); +} diff --git a/crates/engine/tests/long-operator-chains.rs b/crates/engine/tests/long-operator-chains.rs new file mode 100644 index 000000000..97c95ce38 --- /dev/null +++ b/crates/engine/tests/long-operator-chains.rs @@ -0,0 +1,87 @@ +//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is +//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its +//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack +//! nor fall back to an error: Excel computes it. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_engine::{DocState, Session, io}; +use serde_json::json; + +/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`. +fn literal_chain(op: &str) -> String { + format!("={}", vec!["1"; 4096].join(op)) +} + +/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit. +fn reference_chain() -> String { + format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::>().join("+")) +} + +fn value(session: &Session, cell: &str) -> Value { + let at = CellRef::parse(cell).expect("cell"); + session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at) +} + +/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on. +fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack"); +} + +#[test] +fn longest_flat_chains_survive_editing_saving_and_reopening() { + on_small_stack(|| { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values"); + session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain"); + session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain"); + session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain"); + session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain"); + assert_eq!(value(&session, "C1"), Value::Number(4096.0)); + assert_eq!(value(&session, "C2"), Value::Number(-4094.0)); + assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096))); + assert_eq!(value(&session, "C4"), Value::Number(1500.0)); + + // Precedents of a long reference chain still drive recalculation. + session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent"); + assert_eq!(value(&session, "C4"), Value::Number(1600.0)); + + // Evaluate Formula walks every operator of the chain. + let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula"); + assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200)); + assert_eq!(steps["result"], json!(1600.0)); + assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0)); + + // Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501). + session.execute("edit.copy", json!({"range": "C4"})).expect("copy"); + session.execute("edit.paste", json!({"at": "C5"})).expect("paste"); + assert_eq!(value(&session, "C5"), Value::Number(1599.0)); + session.execute("edit.undo", json!({})).expect("undo paste"); + + // Fill and structural edits rewrite every reference in the chain (Excel: an inserted + // row above shifts A1:A1500 to A2:A1501; the sum is unchanged). + session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right"); + assert_eq!(value(&session, "D1"), Value::Number(4096.0)); + assert_eq!(value(&session, "D4"), Value::Number(0.0)); + session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row"); + assert_eq!(value(&session, "C5"), Value::Number(1600.0)); + session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column"); + assert_eq!(value(&session, "D5"), Value::Number(1600.0)); + session.execute("edit.undo", json!({})).expect("undo"); + session.execute("edit.undo", json!({})).expect("undo"); + assert_eq!(value(&session, "C4"), Value::Number(1600.0)); + session.execute("edit.redo", json!({})).expect("redo"); + assert_eq!(value(&session, "C5"), Value::Number(1600.0)); + + // XLSX round trip keeps the formulas and their values. + let wb = &session.doc().expect("doc").wb; + let bytes = io::save_bytes(wb, "chains.xlsx").expect("save"); + let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open"); + let mut session = Session::new(); + session.add_document(DocState::new(reopened, None, "chains".into())); + session.execute("formulas.calculateNow", json!({})).expect("recalc"); + assert_eq!(value(&session, "C2"), Value::Number(4096.0)); + assert_eq!(value(&session, "C3"), Value::Number(-4094.0)); + assert_eq!(value(&session, "C5"), Value::Number(1600.0)); + }); +} diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 9b04140cc..94756c09f 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -182,7 +182,13 @@ impl BinOp { } } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// A formula's syntax tree. +/// +/// A flat operator chain such as `=A1+A2+…` nests one [`Expr::Binary`] per operator on its +/// left, so a formula as long as Excel allows (8,192 characters) is about 4,000 levels deep. +/// `Clone`, `PartialEq`, [`Expr::walk`] and [`Expr::map`] therefore follow that left spine in a +/// loop and recurse only into right-hand operands and real nesting, which the parser bounds. +#[derive(Debug, Serialize, Deserialize)] pub enum Expr { Number(f64), Text(Arc), @@ -206,36 +212,52 @@ pub enum Expr { } impl Expr { - /// Visits every node depth-first. + /// Visits every node depth-first, parents before children, left to right. pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Expr)) { - f(self); - match self { - Expr::Array(rows) => rows.iter().flatten().for_each(|e| e.walk(f)), - Expr::Unary(_, e) | Expr::Paren(e) => e.walk(f), - Expr::Binary(_, a, b) => { - a.walk(f); - b.walk(f); + // An explicit stack: a long operator chain must not recurse once per operand. + let mut pending = vec![self]; + while let Some(e) = pending.pop() { + f(e); + match e { + Expr::Array(rows) => pending.extend(rows.iter().flatten().rev()), + Expr::Unary(_, e) | Expr::Paren(e) => pending.push(e), + Expr::Binary(_, a, b) => { + pending.push(b); + pending.push(a); + } + Expr::Call(_, args) => pending.extend(args.iter().rev()), + Expr::Invoke(c, args) => { + pending.extend(args.iter().rev()); + pending.push(c); + } + _ => {} } - Expr::Call(_, args) => args.iter().for_each(|e| e.walk(f)), - Expr::Invoke(c, args) => { - c.walk(f); - args.iter().for_each(|e| e.walk(f)); - } - _ => {} } } - /// Rewrites every node bottom-up. + /// Rewrites every node bottom-up, children left to right before their parent. pub fn map(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr { - let e = match self { + // Unwind the left spine of a long operator chain into a list, then rebuild it from the + // innermost operand outwards, in the order a recursive rewrite would visit the nodes. + let mut spine = Vec::new(); + let mut node = self; + while let Expr::Binary(op, a, b) = node { + spine.push((op, b)); + node = *a; + } + let e = match node { Expr::Array(rows) => Expr::Array(rows.into_iter().map(|r| r.into_iter().map(|e| e.map(f)).collect()).collect()), Expr::Unary(op, e) => Expr::Unary(op, Box::new(e.map(f))), Expr::Paren(e) => Expr::Paren(Box::new(e.map(f))), - Expr::Binary(op, a, b) => Expr::Binary(op, Box::new(a.map(f)), Box::new(b.map(f))), Expr::Call(n, args) => Expr::Call(n, args.into_iter().map(|e| e.map(f)).collect()), Expr::Invoke(c, args) => Expr::Invoke(Box::new(c.map(f)), args.into_iter().map(|e| e.map(f)).collect()), other => other, }; - f(e) + let mut acc = f(e); + while let Some((op, b)) = spine.pop() { + let b = b.map(f); + acc = f(Expr::Binary(op, Box::new(acc), Box::new(b))); + } + acc } /// All references in the formula. pub fn references(&self) -> Vec<&Reference> { @@ -259,6 +281,75 @@ impl Expr { } } +impl Clone for Expr { + fn clone(&self) -> Self { + // Copy the left spine of an operator chain in a loop (see [`Expr`]). + let mut spine = Vec::new(); + let mut node = self; + while let Expr::Binary(op, a, b) = node { + spine.push((*op, b)); + node = a; + } + let mut acc = match node { + Expr::Number(n) => Expr::Number(*n), + Expr::Text(t) => Expr::Text(t.clone()), + Expr::Bool(b) => Expr::Bool(*b), + Expr::Error(e) => Expr::Error(*e), + Expr::Array(rows) => Expr::Array(rows.clone()), + Expr::Ref(r) => Expr::Ref(r.clone()), + Expr::Name(n) => Expr::Name(n.clone()), + Expr::Struct(s) => Expr::Struct(s.clone()), + Expr::Unary(op, e) => Expr::Unary(*op, e.clone()), + Expr::Binary(op, a, b) => Expr::Binary(*op, a.clone(), b.clone()), + Expr::Call(n, args) => Expr::Call(n.clone(), args.clone()), + Expr::Invoke(c, args) => Expr::Invoke(c.clone(), args.clone()), + Expr::Missing => Expr::Missing, + Expr::Paren(e) => Expr::Paren(e.clone()), + }; + while let Some((op, b)) = spine.pop() { + acc = Expr::Binary(op, Box::new(acc), b.clone()); + } + acc + } +} + +impl PartialEq for Expr { + fn eq(&self, other: &Self) -> bool { + // Compare the left spines of operator chains in a loop (see [`Expr`]). The right-hand + // operands are compared only once the whole spine matches, so a mismatch on the left + // returns before any (possibly deep) right operand is walked. + let (mut x, mut y) = (self, other); + let mut right_operands = Vec::new(); + let equal = loop { + match (x, y) { + (Expr::Binary(o1, a1, b1), Expr::Binary(o2, a2, b2)) => { + if o1 != o2 { + return false; + } + right_operands.push((b1, b2)); + x = a1; + y = a2; + } + (Expr::Number(a), Expr::Number(b)) => break a == b, + (Expr::Text(a), Expr::Text(b)) => break a == b, + (Expr::Bool(a), Expr::Bool(b)) => break a == b, + (Expr::Error(a), Expr::Error(b)) => break a == b, + (Expr::Array(a), Expr::Array(b)) => break a == b, + (Expr::Ref(a), Expr::Ref(b)) => break a == b, + (Expr::Name(a), Expr::Name(b)) => break a == b, + (Expr::Struct(a), Expr::Struct(b)) => break a == b, + (Expr::Unary(o1, a), Expr::Unary(o2, b)) => break o1 == o2 && a == b, + (Expr::Call(n1, a), Expr::Call(n2, b)) => break n1 == n2 && a == b, + (Expr::Invoke(c1, a), Expr::Invoke(c2, b)) => break c1 == c2 && a == b, + (Expr::Missing, Expr::Missing) => break true, + (Expr::Paren(a), Expr::Paren(b)) => break a == b, + _ => break false, + } + }; + equal && right_operands.into_iter().rev().all(|(b1, b2)| b1 == b2) + } +} + pub(crate) fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> { if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None } } diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 8568023ed..017a445b4 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -63,8 +63,9 @@ pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) - /// Prints canonical expressions for editing. Name collisions retain the canonical spelling /// so displaying then committing a formula never changes a defined-name reference. pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { - // Check before cloning/printing: a flat operator chain can produce a deep AST even - // though the parser itself did not recurse deeply. + // Check before cloning/printing. A flat operator chain such as A1+A2+… nests on its left + // without nesting in the text: cloning, printing and `normalize` follow that left spine in + // a loop, so it does not count (Excel accepts 4,096 operands, #916). Other nesting does. let mut pending = vec![(expr, 0usize)]; while let Some((node, depth)) = pending.pop() { if depth > parser::MAX_DEPTH { @@ -73,7 +74,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) match node { Expr::Unary(_, child) | Expr::Paren(child) => pending.push((child, depth + 1)), Expr::Binary(_, left, right) => { - pending.push((left, depth + 1)); + pending.push((left, depth)); pending.push((right, depth + 1)); } Expr::Call(_, args) => pending.extend(args.iter().map(|e| (e, depth + 1))), @@ -128,7 +129,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut if depth > parser::MAX_DEPTH { return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); } - let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name); + let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name); match expr { Expr::Bool(value) if display => { let alias = if *value { "VERDADERO" } else { "FALSO" }; @@ -185,9 +186,15 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut } } Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?, - Expr::Binary(_, left, right) => { - normalize(left, known_name, bindings, depth + 1, display)?; - normalize(right, known_name, bindings, depth + 1, display)?; + Expr::Binary(..) => { + // Follow the left spine of a flat chain (A1+A2+…) in a loop: it is not nesting. + // Each call leaves `bindings` as it found them, so the order does not matter. + let mut node = expr; + while let Expr::Binary(_, left, right) = node { + normalize(right, known_name, bindings, depth + 1, display)?; + node = left; + } + normalize(node, known_name, bindings, depth, display)?; } Expr::Array(rows) if display => { for row in rows { diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 5105dadfb..9bf43d6f3 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -12,8 +12,30 @@ 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::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse, parse_name}; +pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse, parse_closing_parens, parse_name}; pub use printer::{print, print_r1c1, quote_sheet}; +/// Whether two LET/LAMBDA names are the same: case-insensitive in any script, as in Excel +/// (`dług` = `DŁUG`, `ß` = `ẞ`). Close to Unicode's default case folding: lower case, plus the +/// folds lower-casing misses for letters a name may hold (`ß`/`ẞ` -> `ss`, `ſ` -> `s`, `ς` -> `σ`). +/// Dotless `ı` stays distinct from `i`, as outside Turkic languages. +pub fn same_name(a: &str, b: &str) -> bool { + if a.is_ascii() && b.is_ascii() { + return a.eq_ignore_ascii_case(b); + } + let fold = |s: &str| -> String { + s.chars() + .flat_map(char::to_lowercase) + .flat_map(|c| match c { + 'ß' => ['s', 's'].into_iter().take(2), + 'ſ' => ['s', 's'].into_iter().take(1), + 'ς' => ['σ', 'σ'].into_iter().take(1), + c => [c, c].into_iter().take(1), + }) + .collect() + }; + a == b || fold(a) == fold(b) +} + #[cfg(test)] mod tests; diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 17bb4ac27..25759f055 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -34,6 +34,27 @@ pub fn parse_name(src: &str) -> Result { parse_top(src, crate::FormulaLocale::En, true) } +/// Most closing parentheses [`parse_closing_parens`] adds. +const MAX_CLOSED_PARENS: usize = 8; + +/// Parses formula text as typed into a cell with `parse`, closing up to eight missing `)` at the +/// end as Excel does (`SUM(1,2` enters `SUM(1,2)`). When no number of `)` makes it parse, the +/// error describes the text as typed: `1+` is "unexpected end of formula", not "unexpected `)`". +pub fn parse_closing_parens(src: &str, parse: impl Fn(&str) -> Result) -> Result { + let typed = match parse(src) { + Ok(parsed) => return Ok(parsed), + Err(e) => e, + }; + let mut text = src.to_string(); + for _ in 0..MAX_CLOSED_PARENS { + text.push(')'); + if let Ok(parsed) = parse(&text) { + return Ok(parsed); + } + } + Err(typed) +} + pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result { parse_top(src, locale, false) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d398da979..79007c9c5 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -413,3 +413,85 @@ fn sheet_names_excel_quotes_stay_quoted() { assert_eq!(rt(&format!("'{name}'!A1")), format!("{name}!A1"), "{name}"); } } + +#[test] +fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() { + // `=1+1+…` with 4,096 operands is 8,192 characters, Excel's formula limit, and nests 4,095 + // levels on its left. On a 1 MiB stack (the Windows main thread) the derived, recursive + // Clone overflowed when the engine stored such a formula (#916). + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let plus = vec!["1"; 4096].join("+"); + let e = parse(&plus).expect("parse"); + let copy = e.clone(); + assert!(copy == e); + let other = parse(&format!("{}-1", vec!["1"; 4095].join("+"))).expect("parse"); + assert!(other != e); + let mut nodes = 0; + e.walk(&mut |_| nodes += 1); + assert_eq!(nodes, 2 * 4096 - 1); + assert_eq!(print(©.map(&mut |x| x)), plus); + // A chain of references: walk finds them in order, adjust rewrites every one. + let refs = (1..=1000).map(|r| format!("A{r}")).collect::>().join("+"); + let e = parse(&refs).expect("parse"); + let found: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(found.first().map(String::as_str), Some("A1")); + assert_eq!(found.last().map(String::as_str), Some("A1000")); + assert_eq!(found.len(), 1000); + let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 }; + let moved = print(&adjust(e.clone(), "S", "S", &ins)); + assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::>().join("+")); + assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::>().join("+")); + }) + .expect("spawn"); + handle.join().expect("long chains must not overflow the stack"); +} + +#[test] +fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { + // The leftmost operand of a flat chain is not nested, so `(((…1)))+1` is accepted whenever + // `(((…1)))` is, in both the pre-check and `normalize`. + let wrap = |n: usize| { + let mut e = Expr::Number(1.0); + for _ in 0..n { + e = Expr::Paren(Box::new(e)); + } + e + }; + let es = |e: &Expr| crate::print_input(e, FormulaLocale::Es, |_| false); + let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().expect("depth 0 is accepted"); + assert!(deepest > 1 && deepest < 400); + let chain = Expr::Binary(BinOp::Add, Box::new(wrap(deepest)), Box::new(Expr::Number(1.0))); + assert!(es(&chain).is_ok()); +} + +#[test] +fn walk_is_preorder_and_map_is_postorder_left_to_right() { + // `(A1+B1)+SUM(C1,D1)-E1`: a chain with a call on its right. walk visits parents before + // children, map rewrites children (left to right) before their parent. + let e = parse("(A1+B1)+SUM(C1,D1)-E1").unwrap(); + let mut walked = Vec::new(); + e.walk(&mut |n| walked.push(print(n))); + assert_eq!(walked, ["(A1+B1)+SUM(C1,D1)-E1", "(A1+B1)+SUM(C1,D1)", "(A1+B1)", "A1+B1", "A1", "B1", "SUM(C1,D1)", "C1", "D1", "E1"]); + let mut mapped = Vec::new(); + let out = e.map(&mut |n| { + mapped.push(print(&n)); + n + }); + assert_eq!(mapped, ["A1", "B1", "A1+B1", "(A1+B1)", "C1", "D1", "SUM(C1,D1)", "(A1+B1)+SUM(C1,D1)", "E1", "(A1+B1)+SUM(C1,D1)-E1"]); + assert_eq!(print(&out), "(A1+B1)+SUM(C1,D1)-E1"); +} + +#[test] +fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() { + // #2030: `1+` reported "unexpected `)`", the error of the last retry with `)` appended. + let entry = |src: &str| parse_closing_parens(src, parse); + assert_eq!(entry("SUM(1,2").map(|e| print(&e)), Ok("SUM(1,2)".to_string())); + assert_eq!(entry("((1+2").map(|e| print(&e)), Ok("((1+2))".to_string())); + assert_eq!(entry("1+2").map(|e| print(&e)), Ok("1+2".to_string())); + for src in ["1+", "--", "SUM(1,+", "1+*2", &format!("{}1", "(".repeat(9))] { + assert_eq!(entry(src).unwrap_err(), parse(src).unwrap_err(), "{src}"); + } + assert_eq!(entry("1+").unwrap_err().msg, "unexpected end of formula"); +} diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs index fee688a86..e10e1f61b 100644 --- a/crates/formula/tests/spanish_input.rs +++ b/crates/formula/tests/spanish_input.rs @@ -1,4 +1,4 @@ -use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input}; +use gridcraft_formula::{Expr, FormulaLocale, parse, parse_input, print, print_input}; fn input(src: &str) -> String { print(&parse_input(src, FormulaLocale::Es, |_| false).unwrap()) @@ -45,10 +45,31 @@ fn spanish_input_preserves_reference_and_array_syntax() { assert_eq!(print(&parse("SUMA(1,2)+FALSO").unwrap()), "SUMA(1,2)+FALSO"); assert!(parse("SUM(1;2)").is_err()); assert!(parse("{VERDADERO}").is_err()); - // Pratt parsing can produce a deep left-hand AST without deep parser recursion. - let long_chain = "FALSO+".repeat(300) + "VERDADERO"; - assert!(parse_input(&long_chain, FormulaLocale::Es, |_| false).is_err()); - assert!(print_input(&parse(&long_chain).unwrap(), FormulaLocale::Es, |_| false).is_err()); + // Real nesting past the parser's limit is still refused. + let nested = "(".repeat(300) + "1" + &")".repeat(300); + assert!(parse_input(&nested, FormulaLocale::Es, |_| false).is_err()); + let deep = (0..300).fold(Expr::Number(1.0), |e, _| Expr::Paren(Box::new(e))); + assert!(print_input(&deep, FormulaLocale::Es, |_| false).is_err()); +} + +#[test] +fn spanish_input_accepts_flat_chains_as_long_as_excel() { + // A flat chain nests on its left without nesting in the text. Excel accepts 4,096 + // operands (8,192 characters); Spanish entry refused more than 256 (#916). On a 1 MiB + // stack, as on the Windows main thread. + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let long_chain = "FALSO+".repeat(1360) + "VERDADERO"; + let english = "FALSE+".repeat(1360) + "TRUE"; + assert_eq!(input(&long_chain), english); + assert_eq!(print_input(&parse(&english).unwrap(), FormulaLocale::Es, |_| false).unwrap(), long_chain); + let sums = vec!["SUMA(1;2)"; 800].join("+"); + assert_eq!(input(&sums), vec!["SUM(1,2)"; 800].join("+")); + }) + .unwrap() + .join() + .unwrap(); } #[test] @@ -98,3 +119,17 @@ fn spanish_display_round_trips_text_containing_separators() { assert_eq!(shown, r#"SI(A1="a,b;c";CONCAT("1.5";",";";");SUMA(0,25;A1:A3 B2))"#); assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr)); } + +#[test] +fn local_names_match_aliases_in_any_case_like_the_evaluator() { + // `ſi` (long s) is `SI` case-insensitively, so the call is the LAMBDA, not IF. + assert_eq!(input("LET(ſi;LAMBDA(x;x*2);SI(3))"), "LET(ſi,LAMBDA(x,x*2),SI(3))"); + assert!(gridcraft_formula::same_name("ß", "ẞ")); + assert!(gridcraft_formula::same_name("dług", "DŁUG")); + assert!(gridcraft_formula::same_name("ſi", "SI")); + assert!(gridcraft_formula::same_name("σοφός", "ΣΟΦΌΣ")); + // Dotless ı is its own letter outside Turkic languages. + assert!(!gridcraft_formula::same_name("ı", "i")); + assert!(!gridcraft_formula::same_name("ı", "I")); + assert!(!gridcraft_formula::same_name("a", "b")); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 922baabc5..9a61d5b73 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -969,9 +969,9 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { } ui.add_space(18.0); section(ui, lang, "Options|Save"); - let mut autosave = app.grid.autosave(); + let mut autosave = app.ui.autosave; if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { - app.grid.toggle_autosave(); + app.toggle_autosave(); } let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; ui.add_space(18.0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2698c1778..3eb093b0a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -185,6 +185,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + let language_params = format!("{{language: {}}} (alias: code)", crate::i18n::Language::code_list("|")); for (id, label, params) in [ ("ui.ribbonTab", "Ribbon Tab", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), @@ -205,11 +206,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "Submit Print Job", "existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog", ), - ( - "app.language.set", - "Interface Language", - "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"|\"fr\"|\"tr\"} (alias: code)", - ), + ("app.language.set", "Interface Language", language_params.as_str()), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { @@ -546,4 +543,19 @@ mod tests { assert!(!app.printing.busy(), "discovery must not submit a job"); assert!(app.backstage.is_none(), "discovery must not open settings"); } + + #[test] + fn language_command_docs_list_every_language() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { + panic!("command discovery must return immediately"); + }; + let commands = reply["result"].as_array().unwrap(); + let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); + for l in crate::i18n::Language::ALL { + assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); + } + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 83a25cff1..bfab2df19 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2639,7 +2639,7 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: & fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { let r = d.result.clone().unwrap_or(Json::Null); if let Some(e) = r.get("error").and_then(Json::as_str) { - ui.label(crate::clean_error(e)); + ui.label(app.ui.language.tr_error(e)); return; } let issues = r["issues"].as_array().cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index ebc5bd996..8cfa8be0f 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -182,7 +182,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let mut action: Option<(i64, i64, bool, bool)> = None; let mut arrow = false; let mut cancel = false; - if has_focus && mine { + // A message box over the editor (a data validation alert, a formula error) gets Enter and Esc; + // the editor used to take them, so the first Esc cancelled the edit behind the message. + if has_focus && mine && app.message.is_none() { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { let m = i.modifiers; let alt_enter = @@ -297,7 +299,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.fonts_mut(|f| f.layout_job(job)) }; let chars_before = ed.text.chars().count(); - let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; + // The in-cell editor is as tall as its lines (a multiline field reserves four rows). + let in_cell = id == egui::Id::new("gridcraft.cell_editor"); + let edit = if multiline || ed.text.contains('\n') { + TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 }) + } else { + TextEdit::singleline(&mut ed.text) + }; let mut output = edit .id(id) .frame(egui::Frame::NONE) @@ -339,6 +347,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: } ed.point = None; ed.point_anchor = None; + // Typing ends the pointing: the next arrow starts again from the edited cell, as in Excel. + ed.point_cell = None; let names: Vec = function_names() .iter() .map(|name| { diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index c34e3cf86..699167934 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -28,15 +28,7 @@ pub(crate) fn canonical( { let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some(); // Match the engine's existing convenience for missing closing parentheses. - let mut body = body.to_string(); - let mut expr = formula::parse_input(&body, locale, known); - for _ in 0..8 { - if expr.is_ok() { - break; - } - body.push(')'); - expr = formula::parse_input(&body, locale, known); - } + let expr = formula::parse_closing_parens(body, |text| formula::parse_input(text, locale, known)); return Ok(format!("={}", formula::print(&expr?))); } // Decimal-comma number entry follows the same preference, without rewriting text or dates. diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a09f5e4fd..724b8f610 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -120,12 +120,13 @@ pub struct GridState { pub context_menu_frame: u64, /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, + /// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id. + pub object_menu: Option<(Pos2, &'static str, u32)>, pub marching_phase: f32, /// The selection outline on its way to a new selection. pub glide: crate::selection_glide::SelectionGlide, pub last_click: Option<(CellRef, f64)>, pub hover_cell: Option, - pub autosave: bool, pub last_autosave: f64, pub last_border: String, pub last_fill: String, @@ -185,15 +186,6 @@ impl WheelZoom { } } -impl GridState { - pub fn autosave(&self) -> bool { - self.autosave - } - pub fn toggle_autosave(&mut self) { - self.autosave = !self.autosave; - } -} - /// Geometry of the grid for one frame (handles zoom and frozen panes). #[derive(Clone)] pub struct Geo { @@ -795,7 +787,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + object_menu(app, ui); + error_check_tag(app, ui, &geo, sh); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; + // The track starts below the column headings and frozen rows; the bar's column beside them + // takes its colour too instead of showing a light square in dark mode. + ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { // By the clock, not per frame: a gliding outline repaints more often than the ants step. @@ -1388,6 +1385,15 @@ pub(crate) fn paint_quadrant( Stroke::NONE, )); } + // Background error checking: a number stored as text gets Excel's green corner (#1994). + if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], + t.error_check_green, + Stroke::NONE, + )); + } } // Sparklines. for sp in &sh.sparklines { @@ -1792,7 +1798,7 @@ pub(crate) fn paint_headers( quads: &[((u32, u32), (u32, u32), Rect)], headings: &[TableHeading], ) { - let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); + let font = theme::ui_font(heading_font_size(geo.z)); // The headers' own area: inside the outline bands. let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); @@ -2088,10 +2094,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } return; } - // Objects (charts, pictures, shapes, slicers) take clicks first. - if resp.drag_started() || primary_clicked { + // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves + // them: a right-click that wobbles into a drag still opens the object's menu (#2067). + let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); + if primary_drag || primary_clicked { // A drag is recognised only after the pointer has moved, so grab the object where the press began. - let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos }; + let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; if let Some(p) = at && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { @@ -2106,7 +2114,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if kind == "chart" && resp.double_clicked() { app.grid.pane = Some("formatChart".into()); } - if resp.drag_started() { + if primary_drag { let a = object_anchor(sh, kind, id); let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; if let Some(a) = a { @@ -2114,10 +2122,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } return; - } else if primary_clicked || resp.drag_started() { + } else { app.selected_chart = None; } } + // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's + // under it; a right press that wobbles into a drag counts too (#2067). + let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); + if (resp.secondary_clicked() || secondary_drag) + && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } + && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); + return; + } // A double-click is also a click: handle header AutoFit before resize setup returns. if resp.double_clicked() @@ -3004,10 +3023,21 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let st = wb.styles.get(sh.style_id(cell)).clone(); let size = st.font.size * 96.0 / 72.0 * geo.z; let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); - let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; - let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()); - let h = (size * 1.3 * lines + 4.0).max(rect.height()); + // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. + // Otherwise it widens to the text's one-line width. + let wrap = st.align.wrap; + let (w, h) = if wrap { + // The field wraps at the width it is given (the editor's less its 2 px inset a side): its + // frame is `Frame::NONE`, so no text margin is taken off. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); + (rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height())) + } else { + let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; + // Excel keeps the cell's height for one line and only grows for extra lines. + let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; + ((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), h) + }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); let fill = match st.fill.pattern { @@ -3029,7 +3059,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark // theme, on the white paper): pick black or white against the fill, like Excel. child.visuals_mut().text_cursor.stroke.color = ink_on(fill); - crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); + crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0); } /// Test the popup itself, not whether the pointer is over any part of the app. @@ -3293,6 +3323,92 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { } } +/// The right-click menu of a chart, picture, shape or slicer. +fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some((at, kind, id)) = app.grid.object_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::new("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let mut items = vec![("Delete", "object.delete")]; + if kind == "chart" { + items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, action) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match action { + "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), + "ui:formatChart" => app.grid.pane = Some("formatChart".into()), + _ => { + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + } + } + } + } + }); + }); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.object_menu = None; + } +} + +/// The warning button beside an active cell holding a number stored as text, as in Excel: its +/// menu names the problem and offers Convert to Number for the selection (#1994). +fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { + return; + } + let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; + if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { + return; + } + let cell = geo.cell_rect(sh, active); + let size = vec2(18.0, 18.0); + // Left of the cell, or right of it when that would cover the row headings. + let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; + let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); + if !geo.cells.intersects(tag) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // A warning sign: an amber diamond with an exclamation mark. + let c = tag.center(); + let r = 6.0; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], + t.warning_fill, + Stroke::NONE, + )); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); + egui::Popup::menu(&resp).show(|ui| { + ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); + ui.separator(); + if ui.button(lang.tr("Convert to Number")).clicked() { + app.run_or_alert("formulas.convertToNumber", json!({})); + ui.close(); + } + }); +} + /// Used by `EditState` users to keep the active cell visible while pointing. pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { let Some(d) = app.session.active() else { return }; @@ -3728,6 +3844,12 @@ fn painted_halign(h: HAlign, rtl: bool) -> HAlign { } } +/// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the +/// labels are four times their 100% size), and never get smaller than a readable 8.5 points. +pub(crate) fn heading_font_size(zoom: f32) -> f32 { + (11.5 * (zoom / DISPLAY_SCALE)).max(8.5) +} + #[cfg(test)] mod tests { @@ -3744,6 +3866,15 @@ mod tests { assert_eq!(painted_halign(h, true), h); } } + + #[test] + fn heading_labels_scale_with_the_zoom_without_a_cap() { + use super::{DISPLAY_SCALE, heading_font_size}; + let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); + assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); + assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); + assert_eq!(at(10.0), 8.5, "never below a readable size"); + } use super::*; #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4892ad1bd..915a60ab9 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -84,6 +84,12 @@ impl Language { Self::from_tag(code) } + /// Every code `app.language.set` takes, quoted and joined (`"en"|"es"|…`), for command docs + /// and errors. + pub fn code_list(separator: &str) -> String { + Self::ALL.iter().map(|l| format!("\"{}\"", l.code())).collect::>().join(separator) + } + fn from_tag(tag: &str) -> Option { match tag.trim().split(['-', '_']).next()?.to_ascii_lowercase().as_str() { "en" => Some(Self::En), @@ -141,6 +147,15 @@ impl Language { translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } + pub(crate) fn tr_error(self, message: &str) -> String { + let message = if message.starts_with("command `") { crate::clean_error(message) } else { message.to_owned() }; + + let translated = self.tr(&message); + let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); + + if translated == fallback { message } else { translated.to_owned() } + } + /// A worksheet function's description in this language, looked up by the English text of the /// function catalog; `None` when untranslated, and the help shows the English. pub fn function_description(self, english: &str) -> Option<&'static str> { @@ -674,6 +689,10 @@ mod tests { assert_eq!(Language::Ko.tr("Data"), "데이터"); assert_eq!(Language::Ru.tr("Data"), "Данные"); assert_eq!(Language::PtBr.tr("Data"), "Dados"); + assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); + assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); + assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); + assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); assert_eq!(Language::Es.tr("Data"), "Datos"); assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); @@ -856,6 +875,10 @@ mod tests { app.run("app.language.set", serde_json::json!({"code": "ja"})).unwrap(); assert_eq!(app.ui.language, Language::Ja, "`code` is an alias for `language`"); assert!(app.run("app.language.set", serde_json::json!({})).is_err(), "no language is an error, not a dialog"); + let unknown = app.run("app.language.set", serde_json::json!({"language": "xx"})).unwrap_err().to_string(); + for l in Language::ALL { + assert!(unknown.contains(&format!("\"{}\"", l.code())), "the error lists {:?}: {unknown}", l.code()); + } app.run("app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); for l in Language::ALL { diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 8be90f023..cb26e7700 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -58,6 +58,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert" => "Einfügen", "Insert Cells…" => "Zellen einfügen…", "Delete" => "Löschen", + "Select Data…" => "Daten auswählen…", + "Format Chart Area…" => "Diagrammbereich formatieren…", + "Number Stored as Text" => "Als Text gespeicherte Zahl", + "Convert to Number" => "In eine Zahl umwandeln", "Delete Cells…" => "Zellen löschen…", "Insert Sheet Rows" => "Blattzeilen einfügen", "Insert Sheet Columns" => "Blattspalten einfügen", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 83f3ee56c..24a270122 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -421,6 +421,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select a chart first." => "Selecione um gráfico primeiro.", "Text Box" => "Caixa de Texto", "Select Data" => "Selecionar Dados", + "Select Data…" => "Selecionar Dados…", + "Format Chart Area…" => "Formatar Área do Gráfico…", + "Number Stored as Text" => "Número Armazenado como Texto", + "Convert to Number" => "Converter em Número", + "Error checking options" => "Opções de verificação de erros", "Row Height" => "Altura da Linha", "Column Width" => "Largura da Coluna", "Standard Width" => "Largura Padrão", diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 358db5a5f..a9291f327 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -84,6 +84,11 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Semicolon if ctrl && s => Shortcut::Time, Quote if ctrl && s => Shortcut::ValueAbove, Quote if ctrl => Shortcut::FormulaAbove, + // Ctrl+[ / Ctrl+] (Excel): select precedent / dependent cells; Shift walks every level. + OpenBracket if ctrl && !s => run("formulas.selectPrecedents"), + OpenBracket if ctrl => Shortcut::Run("formulas.selectPrecedents", json!({"all": true})), + CloseBracket if ctrl && !s => run("formulas.selectDependents"), + CloseBracket if ctrl => Shortcut::Run("formulas.selectDependents", json!({"all": true})), // Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;. Semicolon if mac && ctrl => Shortcut::Date, Semicolon if mac && cmd && !s => Shortcut::Time, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 32960f8e6..27a3849e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,9 @@ pub struct UiState { /// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed /// from the one matching entry in the same column. pub autocomplete: bool, + /// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every + /// change. A preference like the rest of this struct — it stays on across restarts (#1901). + pub autosave: bool, pub recent: Vec, /// One-shot keytip request for a ribbon control: set when a keytip action resolves to /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. @@ -104,6 +107,7 @@ impl Default for UiState { status_bar: true, animations: true, autocomplete: true, + autosave: false, recent: vec![], keytip_invoke: None, language: i18n::Language::En, @@ -663,9 +667,7 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!( - "unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\", \"de\", \"cs\", \"fr\" or \"tr\")" - )), + None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), } } "app.language.english" => { @@ -1087,7 +1089,7 @@ impl SheetApp { else { return; }; - let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned()); + let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); if matches!(message, printing::Feedback::Submitted(_)) { self.toast = Some((text, now_ms())); } else { @@ -1250,6 +1252,12 @@ impl SheetApp { } } + /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI + /// preferences, so it stays as the user left it across restarts (#1901). + pub fn toggle_autosave(&mut self) { + self.ui.autosave = !self.ui.autosave; + } + /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1313,7 +1321,7 @@ impl SheetApp { } self.perf.last = now; // AutoSave: save workbooks that have a file shortly after they change. - if self.grid.autosave + if self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 @@ -1341,6 +1349,7 @@ impl SheetApp { && self.name_box.is_none() && !ctx.text_edit_focused() && self.grid.context_menu.is_none() + && self.grid.object_menu.is_none() && self.grid.header_menu.is_none() && self.grid.fill_menu.is_none() && self.grid.filter_menu.is_none() diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 62e2cc36d..61c40811c 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -261,7 +261,8 @@ pub fn paint(p: &Painter, geo: &Geo, sh: &Sheet, t: &Tokens) { pc.line_segment(tick, ink); } } - let font = theme::ui_font((10.0 * (geo.z / DISPLAY_SCALE).max(0.75)).min(14.0)); + // The outline levels grow with the zoom like the headings beside them. + let font = theme::ui_font((10.0 * (geo.z / DISPLAY_SCALE)).max(7.5)); for b in &l.buttons { let clip = match b.action { Action::Toggle { rows: true, .. } => row_clip, diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 7c34120bb..7193678c2 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -17,7 +17,7 @@ pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate }; state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels); if let Some(message) = &state.message { - ui.label(feedback(message, translate)); + ui.label(feedback(message, crate::i18n::current(ui.ctx()), translate)); } } @@ -84,7 +84,7 @@ pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option< })) } -pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String { +pub fn feedback(message: &Feedback, language: crate::i18n::Language, translate: impl Fn(&str) -> String) -> String { match message { Feedback::FindingPrinters => translate("Finding printers…"), Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."), @@ -92,7 +92,7 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin Feedback::Submitting => translate("Submitting print job…"), Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."), Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt), - Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error), + Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", &language.tr_error(error)), Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cedfdb6f7..40203072b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -53,14 +53,14 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ui.add_space(TITLE_LEFT_PAD); // AutoSave toggle (saves after each change when the workbook has a file). ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); - let on = app.grid.autosave(); + let on = app.ui.autosave; let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT }); ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside); let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) }; ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim }); if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() { - app.grid.toggle_autosave(); + app.toggle_autosave(); } ui.add_space(6.0); let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); @@ -1714,7 +1714,29 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); control_keytip(app, ui, &s, "pageLayout.size"); egui::Popup::menu(&s).show(|ui| { - for p in ["Letter", "Legal", "Tabloid", "Executive", "A3", "A4", "A5"] { + // B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name. + for p in [ + "Letter", + "Legal", + "Tabloid", + "Executive", + "A3", + "A4", + "A5", + "A6", + "B4", + "B5", + "B6", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Japanese Envelope Kaku #2", + "Japanese Envelope Kaku #3", + "B4 (ISO)", + "B5 (ISO)", + "B6 (ISO)", + ] { if ui.button(p).clicked() { act(app, "pageLayout.size", json!({"paper": p})); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 99bbcf105..c2f96b0c3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -45,6 +45,10 @@ pub struct Tokens { pub menu_bg: Color32, pub shadow: Color32, pub danger: Color32, + /// Background error checking: the corner triangle, and the warning button's diamond and mark. + pub error_check_green: Color32, + pub warning_fill: Color32, + pub warning_ink: Color32, /// Info-bar (notice strip) fills, by severity. pub notice_warn_bg: Color32, pub notice_warn_border: Color32, @@ -88,6 +92,9 @@ impl Tokens { menu_bg: Color32::WHITE, shadow: Color32::from_black_alpha(40), danger: Color32::from_rgb(0xC4, 0x2B, 0x1C), + error_check_green: Color32::from_rgb(0x1E, 0x8E, 0x3E), + warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05), + warning_ink: Color32::BLACK, notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE), notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), @@ -130,6 +137,9 @@ impl Tokens { menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), shadow: Color32::from_black_alpha(90), danger: Color32::from_rgb(0xF1, 0x70, 0x5F), + error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B), + warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05), + warning_ink: Color32::BLACK, notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E), notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 79c87a848..e5322c3ec 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -435,6 +435,8 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { let Some((title, msg)) = app.message.clone() else { return }; + let title = app.ui.language.tr(&title).to_owned(); + let msg = app.ui.language.tr_error(&msg); let mut close = false; egui::Modal::new(egui::Id::new("message_box")).show(ctx, |ui| { ui.set_width(380.0); @@ -456,6 +458,11 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { }); if close { app.message = None; + // Back to the entry the message was about (a validation alert, a formula error), so it can + // be fixed from the keyboard without clicking the cell again. + if let Some(ed) = &mut app.editor { + ed.request_focus = true; + } } } @@ -465,6 +472,7 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { app.toast = None; return; } + let msg = app.ui.language.tr_error(&msg); let Some(rect) = app.grid.rect else { return }; egui::Area::new(egui::Id::new("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show( ctx, diff --git a/crates/ui-egui/tests/autosave_persist.rs b/crates/ui-egui/tests/autosave_persist.rs new file mode 100644 index 000000000..50f3d3b05 --- /dev/null +++ b/crates/ui-egui/tests/autosave_persist.rs @@ -0,0 +1,26 @@ +//! #1901: AutoSave is a preference — it must survive a restart like the other UI settings. + +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +#[test] +fn autosave_survives_a_restart() { + let mut app = SheetApp::new(Session::new(), Default::default()); + assert!(!app.ui.autosave, "off by default"); + app.toggle_autosave(); + assert!(app.ui.autosave, "the toggle flips the persisted flag"); + // What the desktop app writes to ui.json, and reads back on the next launch. + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&saved).unwrap(); + assert!(restored.autosave, "a saved ui.json restores AutoSave on (#1901)"); + app.toggle_autosave(); + assert!(!app.ui.autosave, "toggling back persists off too"); +} + +#[test] +fn an_old_ui_json_without_autosave_still_loads() { + // Files saved by earlier versions have no autosave key; serde(default) reads them as off. + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(r#"{"dark": true}"#).unwrap(); + assert!(!restored.autosave); + assert!(restored.dark, "the rest of the old file still applies"); +} diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/chart_resize.rs index 5aa07e080..fb3e99165 100644 --- a/crates/ui-egui/tests/chart_resize.rs +++ b/crates/ui-egui/tests/chart_resize.rs @@ -117,3 +117,51 @@ fn dragging_the_size_field_resizes_the_chart() { let (w1, _) = size(&h, id); assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}"); } + +fn right_click(h: &mut Harness, pos: egui::Pos2, wobble: egui::Vec2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = pos + wobble; + if wobble != vec2(0.0, 0.0) { + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + } + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn right_clicking_a_chart_opens_its_menu_not_the_cells() { + // #2067: a right-click on a chart opened the menu of the cell under it, or nothing at all. + for wobble in [vec2(0.0, 0.0), vec2(20.0, 10.0)] { + let (mut h, id) = fixture(); + let (w0, h0) = size(&h, id); + let inside = corner(&h, id) - vec2(100.0, 80.0); + right_click(&mut h, inside, wobble); + assert_eq!(h.state().selected_chart, Some(id), "the right-clicked chart is selected ({wobble:?})"); + assert!(matches!(h.state().grid.object_menu, Some((_, "chart", i)) if i == id), "the chart's menu opens ({wobble:?})"); + assert!(h.state().grid.context_menu.is_none(), "the cell menu stays closed ({wobble:?})"); + assert_eq!(size(&h, id), (w0, h0)); + let a = h.state().session.doc().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor; + assert_eq!(a.cell, CellRef::parse("D3").unwrap(), "a right-button drag does not move the chart ({wobble:?})"); + } + // Delete from the menu removes the chart. + let (mut h, id) = fixture(); + let inside = corner(&h, id) - vec2(100.0, 80.0); + right_click(&mut h, inside, vec2(0.0, 0.0)); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty()); + assert!(h.state().grid.object_menu.is_none()); + // Outside the chart a right-click still opens the cell menu. + let (mut h, _) = fixture(); + let cell = { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, CellRef::parse("A10").unwrap()).center() + }; + right_click(&mut h, cell, vec2(0.0, 0.0)); + assert!(h.state().grid.context_menu.is_some() && h.state().grid.object_menu.is_none()); +} diff --git a/crates/ui-egui/tests/in_cell_editor_size.rs b/crates/ui-egui/tests/in_cell_editor_size.rs new file mode 100644 index 000000000..4d82cd711 --- /dev/null +++ b/crates/ui-egui/tests/in_cell_editor_size.rs @@ -0,0 +1,87 @@ +//! The in-cell editor covers the edited cell exactly, as in Excel, at every zoom (#1755). + +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + 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); + h +} + +fn cell_rect(h: &Harness, cell: &str) -> Rect { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()) +} + +fn click_cell(h: &mut Harness, cell: &str) { + let pos = cell_rect(h, cell).center(); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +/// The white editor background painted from the cell's top-left corner. +fn editor_rect(h: &Harness, cell: Rect) -> Rect { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::WHITE && (r.rect.min - cell.min).length() < 0.01 => Some(r.rect), + _ => None, + }) + .next_back() + .expect("in-cell editor background") +} + +fn assert_close(a: Rect, b: Rect, what: &str) { + assert!((a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01, "{what}: editor {a:?} must match cell {b:?}"); +} + +#[test] +fn editor_matches_the_cell_at_every_zoom() { + for zoom in [50u16, 100, 200, 400] { + let mut h = harness(); + h.state_mut().run("view.zoom", json!({"percent": zoom})).unwrap(); + h.run_steps(3); + click_cell(&mut h, "B2"); + type_text(&mut h, "abc"); + assert!(h.state().editor.is_some()); + let cell = cell_rect(&h, "B2"); + assert_close(editor_rect(&h, cell), cell, &format!("zoom {zoom}")); + } +} + +#[test] +fn one_long_line_widens_the_editor_but_keeps_the_row_height() { + let mut h = harness(); + click_cell(&mut h, "B2"); + type_text(&mut h, "a much longer line of text than the cell can hold"); + let cell = cell_rect(&h, "B2"); + let ed = editor_rect(&h, cell); + assert!(ed.width() > cell.width() + 1.0, "long text widens the editor: {ed:?} vs {cell:?}"); + assert!((ed.height() - cell.height()).abs() < 0.01, "one line keeps the row height: {ed:?} vs {cell:?}"); +} diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs new file mode 100644 index 000000000..76fd9a749 --- /dev/null +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -0,0 +1,106 @@ +//! The in-cell editor of a Wrap Text cell keeps the column's width and wraps, growing down, as +//! Excel's does; it used to widen to one long line. +use egui::{Context, Id, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +const TEXT: &str = "Since the 1960s, NASA has left hardware across the solar system, on the Moon, on Mars, and in deep space."; + +/// Runs a few frames of the app in a 1200 × 800 window. +fn frames(ctx: &Context, app: &mut SheetApp) { + for _ in 0..4 { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + } +} + +/// The editor's text field rect while A1 (narrow, wrap on or off) is edited with [`TEXT`]. +fn editor_rect(wrap: bool) -> Rect { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + session.execute("home.wrapText", json!({"range": "A1", "on": wrap})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(TEXT.into()), false); + frames(&ctx, &mut app); + ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect +} + +#[test] +fn a_wrap_text_cell_edits_wrapped_at_the_column_width() { + let wrapped = editor_rect(true); + let one_line = editor_rect(false); + assert!(one_line.width() > 400.0, "without Wrap Text the editor widens to the text: {one_line:?}"); + assert!(wrapped.width() < 120.0, "with Wrap Text the editor keeps the column's width: {wrapped:?}"); + assert!(wrapped.height() > 4.0 * one_line.height(), "and grows down to show every line: {wrapped:?} vs {one_line:?}"); +} + +/// A short Wrap Text edit stays one line tall: the field doesn't reserve rows over the cells below. +#[test] +fn a_short_wrap_text_edit_stays_one_line_tall() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("short".into()), false); + frames(&ctx, &mut app); + let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}"); +} + +/// The editor is sized from the text wrapped where the field wraps it: the editor's width less its +/// 2 px inset a side, with no text margin (the field has no frame). Measured narrower, the editor +/// would count rows the field doesn't have and reach over the cells below. +#[test] +fn the_editor_measures_the_text_at_the_fields_wrap_width() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(TEXT.into()), false); + frames(&ctx, &mut app); + let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + let sheet = app.session.active().unwrap().wb.active().unwrap().clone(); + let st = app.session.active().unwrap().wb.styles.get(sheet.style_id(gridcraft_engine::cell(0, 0))).clone(); + // The grid draws at its zoom: the cell is the field plus the 2 px inset a side, the font scales. + let z = (field.width() + 4.0) / sheet.col_width(0); + let font = egui::FontId::new(st.font.size * 96.0 / 72.0 * z, gridcraft_ui_egui::theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); + let at = + |w: f32| ctx.fonts_mut(|f| f.layout_job(egui::text::LayoutJob::simple(TEXT.into(), font.clone(), egui::Color32::PLACEHOLDER, w)).size().y); + let (own, with_margin) = (at(field.width()), at(field.width() - 8.0)); + assert!(own != with_margin, "the test text must wrap differently 8 px narrower"); + assert!((field.height() - own).abs() < 1.0, "the field wraps at its whole width, no text margin: {} vs {own}", field.height()); +} + +/// An edit with line breaks (Alt+Enter) in a cell without Wrap Text shows every line: the field's +/// one row is a minimum, it grows with the text and never scrolls. +#[test] +fn an_unwrapped_multiline_edit_shows_every_line() { + let field = |text: &str| { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + frames(&ctx, &mut app); + ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect + }; + let one = field("one").height(); + let three = field("one\ntwo\nthree").height(); + assert!(three > 2.5 * one, "three lines are shown: {three} vs one line {one}"); + assert!(three < 3.5 * one, "and no more rows are reserved: {three} vs one line {one}"); +} diff --git a/crates/ui-egui/tests/number_stored_as_text.rs b/crates/ui-egui/tests/number_stored_as_text.rs new file mode 100644 index 000000000..8664cc6d3 --- /dev/null +++ b/crates/ui-egui/tests/number_stored_as_text.rs @@ -0,0 +1,44 @@ +//! A number stored as text gets Excel's green corner and a warning button whose menu converts it (#1994). + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(active: &str) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "B2", "input": "'123"})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": "'abc"})).unwrap(); + s.execute("selection.set", json!({"cell": active})).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(s, Default::default()), + ); + h.run_steps(4); + h +} + +#[test] +fn the_warning_button_converts_a_number_stored_as_text() { + let mut h = harness("B2"); + h.get_by_label("Error checking options").click(); + h.run_steps(3); + h.get_by_label("Convert to Number").click(); + h.run_steps(3); + let v = h.state_mut().session.execute("cell.get", json!({"cell": "B2"})).unwrap()["value"].clone(); + assert_eq!(v, json!(123.0)); + assert!(h.query_by_label("Error checking options").is_none(), "the button goes once the number is a number"); +} + +#[test] +fn text_that_is_not_a_number_gets_no_button() { + let h = harness("B3"); + assert!(h.query_by_label("Error checking options").is_none()); +} diff --git a/crates/ui-egui/tests/scrollbar_column_fill.rs b/crates/ui-egui/tests/scrollbar_column_fill.rs new file mode 100644 index 000000000..cd14f09ba --- /dev/null +++ b/crates/ui-egui/tests/scrollbar_column_fill.rs @@ -0,0 +1,28 @@ +//! The strip beside the column headings, above the vertical scrollbar, takes the bar colour in +//! dark mode instead of showing a light square (#2086). + +use egui::{Shape, pos2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, theme::Tokens}; +use serde_json::json; + +#[test] +fn dark_theme_fills_the_strip_right_of_the_last_column_heading() { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.state_mut().run("view.theme", json!({"mode": "dark"})).unwrap(); + h.run_steps(6); + let grid = h.state().grid.rect.expect("grid rect"); + // Inside the scrollbar column, at the height of the column headings. + let probe = pos2(grid.right() + 7.0, grid.top() + 5.0); + let fill = Tokens::dark().tab_bar; + let covered = h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == fill && r.rect.contains(probe))); + assert!(covered, "no tab_bar fill covers {probe:?}"); +} diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs index e168f3d25..b698927b7 100644 --- a/crates/ui-egui/tests/selection.rs +++ b/crates/ui-egui/tests/selection.rs @@ -286,3 +286,20 @@ fn formula_modifier_shift_click_then_arrow_moves_from_the_clicked_endpoint() { assert_eq!(h.state().editor.as_ref().unwrap().point_cell, CellRef::parse("G7")); } } + +#[test] +fn formula_arrow_pointing_restarts_from_the_edited_cell_after_typing() { + let mut h = harness("B4"); + h.state_mut().begin_edit(Some("=".into()), false); + h.run_steps(2); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=B2"); + frame(&mut h, Event::Text("+".into())); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=B2+B3"); +} diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/trace_navigation.rs new file mode 100644 index 000000000..11f79fcff --- /dev/null +++ b/crates/ui-egui/tests/trace_navigation.rs @@ -0,0 +1,117 @@ +//! Ctrl+[ / Ctrl+] select precedent and dependent cells (#1865). + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + 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 active(h: &Harness) -> String { + let d = h.state().session.active().unwrap(); + d.selection.active.a1() +} + +fn ranges(h: &Harness) -> Vec { + let d = h.state().session.active().unwrap(); + d.selection.ranges.iter().map(|r| r.a1()).collect() +} + +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); +} + +/// A1 and B1 feed C1; C1 feeds D1. +fn chain() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A1+B1"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "=C1*2"})).unwrap(); + s +} + +#[test] +fn ctrl_bracket_selects_precedents_and_dependents() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "C1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + let mut r = ranges(&h); + r.sort(); + assert_eq!(r, vec!["A1".to_string(), "B1".to_string()], "Ctrl+[ selects the precedent cells"); + h.state_mut().session.execute("selection.set", json!({"range": "C1"})).unwrap(); + key(&mut h, Key::CloseBracket, Modifiers::CTRL); + assert_eq!(active(&h), "D1", "Ctrl+] lands on the dependent cell"); +} + +#[test] +fn ctrl_shift_bracket_walks_every_level() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "D1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL | Modifiers::SHIFT); + let r = ranges(&h); + assert!( + r.contains(&"A1".to_string()) && r.contains(&"B1".to_string()) && r.contains(&"C1".to_string()), + "Ctrl+Shift+[ walks back through C1 to A1:B1, got {r:?}" + ); +} + +#[test] +fn transitive_walk_reaches_formulas_late_in_a_wide_area() { + // =SUM(A1:Z1) has a wide precedent area; a formula stored late in it must still be + // walked, and blank positions must not be selected (#1865 review). + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "Z1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "AB1", "input": "=SUM(A1:Z1)"})).unwrap(); + s.execute("cell.set", json!({"cell": "AD1", "input": "=AB1*2"})).unwrap(); + let mut h = harness(s); + h.state_mut().session.execute("selection.set", json!({"range": "AD1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL | Modifiers::SHIFT); + let r = ranges(&h); + assert!(r.contains(&"Z1".to_string()), "the formula late in the area is reached, got {r:?}"); + assert_eq!(r.len(), 2, "only the stored cells (AB1, Z1) are selected, no blanks, got {r:?}"); +} + +#[test] +fn no_precedents_leaves_selection_untouched() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "A1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + assert_eq!(active(&h), "A1", "a plain entry cell has no precedents to select"); +} + +#[test] +fn cross_sheet_precedents_jump_to_the_other_sheet() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Src"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("cell.set", json!({"cell": "Src!B2", "input": "42"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=Src!B2*2"})).unwrap(); + // Back on the first sheet, Ctrl+[ from A1 jumps to Src!B2. + let d = s.active().unwrap(); + let first = d.wb.active_sheet; + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut h = harness(s); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + let d = h.state().session.active().unwrap(); + assert_ne!(d.wb.active_sheet, first, "selection jumped to the precedent's sheet"); + assert_eq!(d.selection.active, CellRef { row: 1, col: 1 }, "B2 on the Src sheet"); +} diff --git a/crates/ui-egui/tests/validation_alert.rs b/crates/ui-egui/tests/validation_alert.rs new file mode 100644 index 000000000..eb80822c9 --- /dev/null +++ b/crates/ui-egui/tests/validation_alert.rs @@ -0,0 +1,105 @@ +//! A data validation alert over the cell editor answers Enter and Esc itself, and closing it +//! puts the keyboard back in the entry so it can be fixed straight away. + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const MESSAGE: &str = "This value doesn't match the data validation restrictions defined for this cell."; + +/// A1 holds 5 and accepts whole numbers from 1 to 10 (Stop alert). +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "between", "formula1": "1", "formula2": "10"})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.language = Language::En; + 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); + }, + app, + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, k: Key) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// Types `50` into A1 and presses Enter, which opens the alert. +fn enter_50(h: &mut Harness) { + h.state_mut().begin_edit(Some("50".into()), false); + h.run_steps(2); + key(h, Key::Enter); + assert!(alert_shown(h)); +} + +/// Types text the way a keyboard does (into whatever has focus). +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn alert_shown(h: &Harness) -> bool { + h.query_by_label(MESSAGE).is_some() +} + +fn entry(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.text.clone()) +} + +fn a1(h: &Harness) -> Value { + h.state().session.active().unwrap().wb.sheet(0).unwrap().value(CellRef::new(0, 0)) +} + +/// The first Esc closes the alert and the entry is still there to fix, with the keyboard in it; a +/// second Esc cancels the edit as usual. Before, the first Esc cancelled the edit behind the alert +/// and the second closed it, and then typing went nowhere until the cell was clicked. +#[test] +fn esc_closes_a_validation_alert_and_leaves_the_entry() { + let mut h = harness(); + enter_50(&mut h); + key(&mut h, Key::Escape); + assert!(!alert_shown(&h), "one Esc closes the alert"); + assert_eq!(entry(&h), Some("50".to_string()), "the entry is still open behind it"); + type_text(&mut h, "9"); + assert_eq!(entry(&h), Some("509".to_string()), "typing goes into the entry"); + key(&mut h, Key::Escape); + assert_eq!(entry(&h), None, "the next Esc cancels the edit"); + assert_eq!(a1(&h), Value::Number(5.0)); +} + +/// Enter closes the alert too, without touching the entry, and the entry can be fixed and +/// committed from the keyboard. Before, the editor took Enter, tried the same entry again and the +/// alert stayed. +#[test] +fn enter_closes_a_validation_alert_and_leaves_the_entry() { + let mut h = harness(); + enter_50(&mut h); + key(&mut h, Key::Enter); + assert!(!alert_shown(&h), "Enter closes the alert"); + assert_eq!(entry(&h), Some("50".to_string())); + assert_eq!(a1(&h), Value::Number(5.0)); + key(&mut h, Key::Backspace); + key(&mut h, Key::Backspace); + type_text(&mut h, "8"); + assert_eq!(entry(&h), Some("8".to_string()), "the entry is fixed from the keyboard"); + key(&mut h, Key::Enter); + assert_eq!(entry(&h), None); + assert_eq!(a1(&h), Value::Number(8.0), "the fixed entry goes in"); + assert!(!alert_shown(&h)); +} diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index a3dfe0d20..edb08e732 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -22,7 +22,9 @@ impl Shape { pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula) -> bool { let Some(expr) = formula.expr_arc() else { return false }; - let mut scan = Scan { wb, at, remaining: 4096, locals: Vec::new() }; + // The budget covers every node of a formula as long as Excel allows (8,192 characters make + // at most about 8,200 nodes), so only names that expand a lot fall back to array evaluation. + let mut scan = Scan { wb, at, remaining: 16_384, locals: Vec::new() }; let shape = scan.expr(&expr, sheet, false, 0); // An explicit `@` is written as `_xlfn.SINGLE`, which only dynamic-array formulas carry. let mut at = false; @@ -89,11 +91,27 @@ impl Scan<'_> { } } Expr::Binary(op, a, b) => { - let a = self.expr(a, sheet, array_context, depth + 1); - let b = self.expr(b, sheet, array_context, depth + 1); - let reference = matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect); - let array = reference || a.array || b.array; - Shape { array, affected: a.affected || b.affected || (!reference && array && !array_context) } + // A flat chain such as A1+A2+… nests one level per operator on its left: follow + // it in a loop, so a long chain is neither deep recursion nor past the depth + // limit (which would write a plain sum as an array formula). + let mut spine = vec![(*op, b.as_ref())]; + let mut left = a.as_ref(); + while let Expr::Binary(op, a, b) = left { + if self.remaining == 0 { + return Shape { array: true, affected: true }; + } + self.remaining -= 1; + spine.push((*op, b.as_ref())); + left = a.as_ref(); + } + let mut acc = self.expr(left, sheet, array_context, depth + 1); + while let Some((op, b)) = spine.pop() { + let b = self.expr(b, sheet, array_context, depth + 1); + let reference = matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect); + let array = reference || acc.array || b.array; + acc = Shape { array, affected: acc.affected || b.affected || (!reference && array && !array_context) }; + } + acc } Expr::Call(name, args) => self.call(name, args, sheet, array_context, depth + 1), Expr::Invoke(_, _) => Shape { array: true, affected: false }, diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index ef263873e..bef43a5aa 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -30,7 +30,21 @@ fn prefix_params(e: Expr, scope: &[String]) -> Expr { Expr::Name(n) if scope.iter().any(|s| s.eq_ignore_ascii_case(&n)) => Expr::Name(format!("{PARAM_PREFIX}{n}")), Expr::Unary(op, x) => Expr::Unary(op, sub(x, scope)), Expr::Paren(x) => Expr::Paren(sub(x, scope)), - Expr::Binary(op, a, b) => Expr::Binary(op, sub(a, scope), sub(b, scope)), + Expr::Binary(op, a, b) => { + // A long chain such as A1+A2+… nests on its left: follow it in a loop, not one + // level of recursion per operand. + let mut spine = vec![(op, b)]; + let mut left = *a; + while let Expr::Binary(op, a, b) = left { + spine.push((op, b)); + left = *a; + } + let mut acc = prefix_params(left, scope); + while let Some((op, b)) = spine.pop() { + acc = Expr::Binary(op, Box::new(acc), sub(b, scope)); + } + acc + } Expr::Invoke(c, args) => Expr::Invoke(sub(c, scope), args.into_iter().map(|a| prefix_params(a, scope)).collect()), Expr::Call(name, args) if name == "LET" || name == "LAMBDA" => { let last = args.len().saturating_sub(1); diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index aba547f79..141d8673a 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -54,8 +54,10 @@ pub fn builtin_id(code: &str) -> Option { pub use gridcraft_core::color::INDEXED; -/// Paper sizes (`pageSetup paperSize`) we name. -const PAPERS: [(u32, &str); 14] = [ +/// Paper sizes (`pageSetup paperSize`) we name. ID 12/13 are the JIS B4/B5 that +/// `pageLayout.pageSetup` calls `B4`/`B5`; 42 is ISO B4, and 43–88 are the Japanese postcards, +/// envelopes and B6. The engine's `known_paper` uses the same names and sizes. +const PAPERS: [(u32, &str); 23] = [ (1, "Letter"), (3, "Tabloid"), (4, "Ledger"), @@ -70,14 +72,40 @@ const PAPERS: [(u32, &str); 14] = [ (20, "Envelope #10"), (27, "Envelope DL"), (34, "Envelope B5"), + (42, "B4 (ISO)"), + (43, "Japanese Postcard"), + (69, "Japanese Double Postcard"), + (70, "A6"), + (71, "Japanese Envelope Kaku #2"), + (72, "Japanese Envelope Kaku #3"), + (73, "Japanese Envelope #3"), + (74, "Japanese Envelope #4"), + (88, "B6"), ]; pub fn paper_name(id: u32) -> Option<&'static str> { PAPERS.iter().find(|(i, _)| *i == id).map(|(_, n)| *n) } +/// The file ID for a paper name. Spelling is loose like `known_paper`'s — spaces, hyphens, +/// underscores and parentheses don't matter, and the JIS/ISO and Japanese-envelope spellings the +/// page-setup commands take are folded onto the name in [`PAPERS`]. pub fn paper_id(name: &str) -> Option { - PAPERS.iter().find(|(_, n)| n.eq_ignore_ascii_case(name.trim())).map(|(i, _)| *i) + let norm = |s: &str| s.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); + let normalized = norm(name); + let alias = match normalized.as_str() { + "b4jis" => "b4", + "b5jis" => "b5", + "b6jis" => "b6", + "postcard" => "japanesepostcard", + "doublepostcard" => "japanesedoublepostcard", + "chou#3" => "japaneseenvelope#3", + "chou#4" => "japaneseenvelope#4", + "kaku#2" => "japaneseenvelopekaku#2", + "kaku#3" => "japaneseenvelopekaku#3", + other => other, + }; + PAPERS.iter().find(|(_, n)| norm(n) == alias).map(|(i, _)| *i) } /// Functions introduced after Excel 2007 that files store with the `_xlfn.` prefix. Entries with @@ -329,5 +357,26 @@ mod tests { assert_eq!(builtin_id(builtin_format(5).unwrap()), None); assert_eq!(paper_name(9), Some("A4")); assert_eq!(paper_id("a4"), Some(9)); + // 12/13 are the JIS B4/B5 Japanese Excel uses; 42 is ISO B4. + assert_eq!(paper_name(12), Some("B4")); + assert_eq!(paper_id("b5"), Some(13)); + assert_eq!(paper_id("B5 (JIS)"), Some(13)); + assert_eq!(paper_id("B4 (ISO)"), Some(42)); + // The Japanese papers page setup names. + assert_eq!(paper_name(43), Some("Japanese Postcard")); + assert_eq!(paper_name(88), Some("B6")); + for (name, id) in [ + ("Japanese Postcard", 43), + ("japanese double postcard", 69), + ("A6", 70), + ("Japanese Envelope Kaku #2", 71), + ("kaku #3", 72), + ("Japanese Envelope #3", 73), + ("chou #4", 74), + ("B6 (JIS)", 88), + ] { + assert_eq!(paper_id(name), Some(id), "{name}"); + } + assert_eq!(paper_id("Napkin"), None); } } diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index b90b98668..0dcc9d81c 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -409,3 +409,28 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() { &[("Data[@Input]*2", false), ("SUM(Data[Input])", false), ("SUM(Data[Input]*2)", true), ("SQRT(Data[Input])", true)], ); } + +#[test] +fn long_flat_chains_are_scalar_formulas() { + // `=A1+A2+…` nests one level per operator. Past the scan's depth limit of 64, a plain sum + // of 66 cells was written as a dynamic-array formula; Excel writes it as a plain formula. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + let refs = (1..=1500).map(|r| format!("A{r}")).collect::>().join("+"); + let mixed = (1..=1000).map(|r| format!("A{r}*2")).collect::>().join("-"); + let literals = vec!["1"; 4096].join("&"); + let ranges = format!("{refs}+A1:A3"); + sh.cells.set(at("B1"), formula(&refs, 0.0)); + sh.cells.set(at("B2"), formula(&mixed, 0.0)); + sh.cells.set(at("B3"), formula(&literals, 0.0)); + sh.cells.set(at("B4"), formula(&ranges, 0.0)); + let bytes = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for address in ["B1", "B2", "B3"] { + assert_scalar(&sheet, address); + } + // A range anywhere in the chain still needs array evaluation. + assert_dynamic(&sheet, "B4", "B4"); + assert_eq!(cell(&sheet, "B1").child("f").unwrap().text, refs); +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index dcf47af7d..97ca37765 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1210,3 +1210,35 @@ fn doc_props_keep_boundary_whitespace() { let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); assert_eq!(back.props, wb.props); } + +/// Japanese paper sizes survive an XLSX save and reopen. IDs 12/13 are the JIS B4/B5; the +/// Japanese postcards and envelopes have their own IDs, and `B4 (ISO)` keeps ISO B4. The ISO B5/B6 +/// names only exist in the engine: ECMA-376 has no `paperSize` id for them (only ISO B4), so they +/// are not part of the round-trip. +#[test] +fn japanese_paper_sizes_round_trip() { + for paper in [ + "B4", + "B5", + "B6", + "B4 (ISO)", + "A6", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Japanese Envelope Kaku #2", + "Japanese Envelope Kaku #3", + ] { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().print.paper = paper.into(); + let (back, report) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(report.warnings.is_empty(), "{paper}: {:?}", report.warnings); + assert_eq!(back.sheet(0).unwrap().print.paper, paper); + } + // An alias is written as the file's own name and read back as that name. + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().print.paper = "B5 (JIS)".into(); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheet(0).unwrap().print.paper, "B5"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index baf8e730c..ab495bce1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,8 +56,9 @@ the View ▸ Animations checkbox, saved with the other UI preferences and on by `{command: "app.language.set", params: {language: "ja"}}` switches the interface language (`code` is accepted as an alias for `language`); `app.language.english` and -`app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or -document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the +`app.language.japanese` are shortcuts. `engine.commands` lists every code it takes (`en`, `es`, +`zh`, `ja`, `ko`, `ru`, `pt`, `de`, `cs`, `fr`, `sr`), as does the error for an unknown one. Only +interface chrome is translated, never command ids or document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the system language. `{command: "file.open", params: {path: "Secret.xlsx", password: "…"}}` opens a password-protected diff --git a/docs/function-parity.md b/docs/function-parity.md index 8688f405c..3e419924c 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -84,6 +84,7 @@ PIVOTBY, REGEXTEST…) are all present. | 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | +| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | | Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA` done; `#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#BUSY!`, `#UNKNOWN!`, `#PYTHON!` missing (minor: they come from data types, cloud and Python features) | ## Depth (estimated) @@ -114,6 +115,8 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) | +| 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands | | 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-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) | | 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b623124d9..56449a3f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and row formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, with ISO B4/B5/B6 by name and the Japanese postcards, envelopes and A6 added; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -86,7 +86,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | -| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 03773034c..bcbff0d99 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-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Ctrl/Option-drag a sheet tab drops a copy, #971; formula-entry Command/Ctrl-click adds arguments and Shift-click extends a range; More Colors… and Recent Colors, #928; stable grid drag cursors, #936) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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 @@ -168,6 +168,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | +| 2026-10-20 | minor | Esc and Enter close a message over the cell editor, such as a data validation alert, and the entry stays open to fix, with the keyboard back in it. Before, the editor took them (the first Esc cancelled the edit behind the alert and only the second closed it), and after the message typing went nowhere until the cell was clicked | | 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-19 | trivial | While a mouse button is held (a click or a drag selecting cells) the selection outline is drawn on its cells at once instead of gliding after the pointer, which read as lag; keyboard moves still glide | | 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) |