diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..a78c01817 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,14 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | +| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | +| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | +| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years | +| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) | +| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does | +| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share | +| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | @@ -181,6 +189,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | +| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -254,6 +263,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | +| 2026-10-11 | minor | UI building blocks; progress log row | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/contributors/people.toml b/contributors/people.toml index 43252507d..5ce80fe59 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -99,3 +99,7 @@ real_name = "Tyrel Haveman" [people.helenkwok] real_name = "Helen Kwok" + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 931854732..4bdb3a30d 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -58,6 +58,24 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build. +/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132 +/// calls in progress, so it still runs. +const MAX_LAMBDA_CALLS: usize = 140; + +/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, +/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames +/// above the formula. A cell that another cell's formula computes on demand shares the budget of the +/// outermost formula ([`Host::stack_top`]). +const STACK_BUDGET: usize = 896 * 1024; + +/// The address of a local variable, which shows how far down the native stack this call is. +#[inline(always)] +pub(crate) fn stack_position() -> usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + /// What the evaluator needs from its surroundings. pub trait Host { fn workbook(&self) -> &Workbook; @@ -85,6 +103,13 @@ pub trait Host { fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { false } + /// While a formula is calculated on demand inside another one: `stack_position()` where the + /// outermost one started, which the stack budget is counted from. + fn stack_top(&self) -> Option { + None + } + /// Called when a formula's evaluation runs past the stack budget. + fn stack_exhausted(&mut self) {} } pub struct Evaluator<'h> { @@ -93,6 +118,10 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// LAMBDA calls in progress. + calls: usize, + /// `stack_position()` at the outermost `eval`. + stack_top: usize, /// Lambda invocations this cell still has left. Every call spends one charge, so a /// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays) /// runs out of work after the same number of calls that a legitimately wide one could @@ -137,7 +166,16 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, fuel: self.ev.fuel }; + let mut ev = Evaluator { + host: &mut *self.ev.host, + sheet: area.sheet, + at, + env, + depth: self.ev.depth, + calls: self.ev.calls, + stack_top: self.ev.stack_top, + fuel: self.ev.fuel, + }; let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)); // The child's spend counts against this cell's budget. self.ev.fuel = ev.fuel; @@ -160,7 +198,7 @@ impl Ctx for FnCtx<'_, '_> { impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0, fuel: LAMBDA_FUEL } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -246,10 +284,18 @@ impl<'h> Evaluator<'h> { } pub fn eval(&mut self, e: &Expr) -> Ev { + let here = stack_position(); + if self.depth == 0 { + self.stack_top = self.host.stack_top().unwrap_or(here); + } + if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.host.stack_exhausted(); + return err(CellError::Num); + } self.depth += 1; if self.depth > MAX_DEPTH { self.depth -= 1; - return err(CellError::Calc); + return err(CellError::Num); } let r = self.eval_inner(e); self.depth -= 1; @@ -533,7 +579,7 @@ impl<'h> Evaluator<'h> { } _ => { // A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead - // of one level of recursion per operator (past the depth limit it was #CALC!). + // of one level of recursion per operator (past the depth limit it was an error). let mut rest = vec![(op, b)]; let mut left = a; while let Expr::Binary(o, l, r) = left @@ -842,10 +888,11 @@ impl<'h> Evaluator<'h> { fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { let Some(spec) = gridcraft_functions::lookup(name) else { // A defined name holding a LAMBDA, or a LET-bound lambda. - if let Ev::L(l) = self.name(name) { - return self.apply_lambda(&l, args); - } - return err(CellError::Name); + return match self.name(name) { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Name), + }; }; if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); @@ -996,6 +1043,9 @@ impl<'h> Evaluator<'h> { } fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + if self.calls >= MAX_LAMBDA_CALLS { + return err(CellError::Num); + } // #2382: one charge per invocation, so a cell's total work stays bounded however the // lambdas nest. Foregrounding the charge here means every path into a body (direct // `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters. @@ -1003,10 +1053,12 @@ impl<'h> Evaluator<'h> { Some(f) => f, None => return err(CellError::Calc), }; + self.calls += 1; let saved = std::mem::replace(&mut self.env, l.env.clone()); self.env.extend(bound); let r = self.eval(&l.body); self.env = saved; + self.calls -= 1; r } diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 66374bed2..4cbbcc8b4 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents}; +use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; pub type Key = (usize, CellRef); @@ -432,6 +432,10 @@ struct PassHost<'a> { tainted: HashSet, /// Whether the formula being evaluated has read a previous value. taint: bool, + /// `stack_position()` where the outermost formula being calculated started. + stack_top: usize, + /// Whether an evaluation ran past the stack budget since the innermost formula started. + out_of_stack: bool, } impl<'a> PassHost<'a> { @@ -462,6 +466,8 @@ impl<'a> PassHost<'a> { members: HashSet::default(), tainted: HashSet::default(), taint: false, + stack_top: 0, + out_of_stack: false, } } } @@ -580,6 +586,9 @@ impl PassHost<'_> { self.suspend = Some(k); return Value::Error(CellError::Calc); } + if self.depth == 0 { + self.stack_top = stack_position(); + } self.in_progress.insert(k); self.depth += 1; let outer_taint = std::mem::replace(&mut self.taint, false); @@ -587,10 +596,24 @@ impl PassHost<'_> { self.stack.push(k); } let expr = std::sync::Arc::clone(expr); + let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); let v = { let mut ev = Evaluator::new(self, k.0, k.1); ev.value(&expr) }; + let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); + if out_of_stack && self.depth > 1 && self.suspend.is_none() { + // It ran out of the stack left below the formulas that read it: it is calculated + // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. + self.suspend = Some(k); + self.depth -= 1; + self.in_progress.remove(&k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } if self.suspend.is_some() { // Abandoned: `calculate` evaluates this formula again once the one it needs is done. self.depth -= 1; @@ -745,6 +768,12 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn stack_top(&self) -> Option { + (self.depth > 1).then_some(self.stack_top) + } + fn stack_exhausted(&mut self) { + self.out_of_stack = true; + } fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { let k = (sheet, anchor); if !(self.pending.contains(&k) || self.results.contains_key(&k)) { @@ -913,6 +942,7 @@ impl Calc { /// Rebuilds the graph from scratch. pub fn rebuild(&mut self, wb: &Workbook) { self.graph = Graph::default(); + let mut circ = Vec::new(); for (si, sheet) in wb.sheets.iter().enumerate() { for (c, cell) in sheet.cells.iter() { if let Some(f) = &cell.formula @@ -920,10 +950,106 @@ impl Calc { { let (areas, dynamic) = precedents(wb, si, &e); self.graph.insert((si, c), Node { areas, dynamic }); + if cell.value == Value::Error(CellError::Circ) { + circ.push((si, c)); + } } } } self.graph.settle(); + self.circular = if wb.calc.iterative { Vec::new() } else { self.loops_among(&circ) }; + } + + /// The circular references of a rebuilt workbook (after undo, redo or a rollback, which put + /// back cells without calculating them), from the formulas whose cached value is `#CIRC!`: + /// the listed cells still on a loop, and one cell of each loop none of them is on. + fn loops_among(&self, circ: &[Key]) -> Vec { + if circ.is_empty() { + return Vec::new(); + } + let index: HashMap = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + // Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way. + let mut deps = Vec::new(); + let edges: Vec> = circ + .iter() + .map(|k| { + deps.clear(); + self.graph.dependents(k.0, k.1, &mut deps); + let mut e: Vec = deps.iter().filter_map(|d| index.get(d).copied()).collect(); + e.sort_unstable(); + e.dedup(); + e + }) + .collect(); + // Tarjan's strongly connected components, iterative. + let n = circ.len(); + let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]); + let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]); + let (mut next, mut comps) = (0usize, 0usize); + for root in 0..n { + if num[root] != usize::MAX { + continue; + } + let mut call: Vec<(usize, usize)> = vec![(root, 0)]; + num[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + while let Some(top) = call.last_mut() { + let v = top.0; + if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) { + top.1 += 1; + if num[w] == usize::MAX { + num[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + call.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(num[w]); + } + continue; + } + call.pop(); + if let Some(&(u, _)) = call.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == num[v] { + while let Some(w) = stack.pop() { + on_stack[w] = false; + comp[w] = comps; + if w == v { + break; + } + } + comps += 1; + } + } + } + let mut size = vec![0usize; comps]; + for &c in &comp { + if let Some(s) = size.get_mut(c) { + *s += 1; + } + } + let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i)); + // A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see. + let mut out: Vec = + self.circular.iter().copied().filter(|k| index.get(k).is_some_and(|&i| on_loop(i) || self.graph.dynamic.contains(k))).collect(); + let mut covered: HashSet = out.iter().filter_map(|k| index.get(k)).filter_map(|&i| comp.get(i).copied()).collect(); + let mut order: Vec = (0..n).collect(); + order.sort_by_key(|&i| circ.get(i).map(|(s, c)| (*s, c.row, c.col))); + for i in order { + if let (Some(&c), Some(&k)) = (comp.get(i), circ.get(i)) + && on_loop(i) + && covered.insert(c) + { + out.push(k); + } + } + out } /// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading). @@ -1103,7 +1229,12 @@ impl Calc { } let tw = prof_now(); self.last_recalc_cells += results.len(); - self.circular = cycles; + // The loops this round finds replace the ones listed for the formulas it evaluated. + if !self.circular.is_empty() { + let evaluated: HashSet = dirty.iter().copied().collect(); + self.circular.retain(|k| !evaluated.contains(k)); + } + self.circular.extend(cycles); // Write back. let mut spill_changes: Vec = Vec::new(); let mut freed: Vec<(usize, RangeRef)> = Vec::new(); @@ -1216,6 +1347,13 @@ impl Calc { } dirty = self.dirty_closure(wb, &spill_changes); } + // Loops among formulas the run did not evaluate stay listed while they are still formulas. + // Iterative calculation reports none. + if wb.calc.iterative { + self.circular.clear(); + } else { + self.circular.retain(|k| self.graph.nodes.contains_key(k)); + } } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index ef21c49e3..5b9e90085 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -384,6 +384,32 @@ fn circular_references_without_iterative_calculation() { assert_eq!(t.get("A1"), Value::Error(CellError::Circ)); } +/// The list of circular references keeps the loops that a recalculation of other cells does not +/// reach, and drops a loop once a constant is typed over one of its cells. +#[test] +fn circular_references_stay_listed_until_the_loop_is_removed() { + let mut t = T::new(); + t.set("A1", "=A1+1"); + t.set("B1", "=B2"); + t.set("B2", "=B1"); + assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + t.set("D4", "7"); + t.set("D5", "=D4*2"); + assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular); + t.set("A1", "5"); + assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular); + t.set("B2", "3"); + assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular); + // In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it. + t.wb.calc.mode = gridcraft_model::CalcMode::Manual; + t.set("C1", "=C1+1"); + assert_eq!(t.calc.circular, vec![(0, c("C1"))]); + t.set("C1", "1"); + assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular); +} + #[test] fn date_text_coerces_in_the_workbook_date_system() { for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] { @@ -1025,6 +1051,100 @@ fn lambda_argument_count() { assert_eq!(t.get("A9"), Value::Bool(true)); } +#[test] +fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { + // 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run. + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let mut t = T::new(); + t.wb.names.push(gridcraft_model::DefinedName { + name: "LOOP".into(), + scope: None, + formula: "LAMBDA(n,LOOP(n))".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))"; + t.set("A1", endless); + t.set("A2", "=LOOP(1)"); + t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))"); + t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))"); + let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); + [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] + }) + .expect("spawn"); + let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack"); + assert_eq!(endless, Value::Error(CellError::Num)); + assert_eq!(endless_name, Value::Error(CellError::Num)); + assert_eq!(through_map, Value::Error(CellError::Num)); + assert_eq!(evaluated, Value::Error(CellError::Num)); + assert_eq!(countdown, Value::Number(131.0)); +} + +#[test] +fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + let mut wb = Workbook::new(); + wb.names.push(gridcraft_model::DefinedName { + name: "CNT".into(), + scope: None, + formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(), + comment: String::new(), + hidden: false, + }); + // F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step. + let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))"); + let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))"); + [tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f)) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]); + assert_eq!(run(64 << 20), small); +} + +#[test] +fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + // Row-major order calculates A1 first, so each cell below is calculated on demand + // at the deepest call of the one above it. + let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))"); + let mut wb = Workbook::new(); + let sheet = wb.sheet_mut(0).expect("sheet"); + for (at, f) in [ + ("A1", countdown("A2")), + ("A2", countdown("A3")), + ("A3", countdown("0")), + ("B1", countdown("B2")), + ("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()), + ] { + sheet.cells.set(c(at), Cell::formula(Formula::new(&f))); + } + let mut calc = Calc::new(); + calc.recalc_all(&mut wb); + ["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default()) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + let num = Value::Error(CellError::Num); + assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]); + assert_eq!(run(64 << 20), small); +} + #[test] fn names_and_sheets() { let mut t = T::new(); diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 0a6fdbe32..e97f576a1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -868,6 +868,7 @@ enum Fam { LineStack, Area, AreaStack, + Area100, } fn family(k: ChartKind) -> Fam { @@ -879,10 +880,16 @@ fn family(k: ChartKind) -> Fam { LineStacked => Fam::LineStack, Area => Fam::Area, AreaStacked => Fam::AreaStack, + AreaStacked100 => Fam::Area100, _ => Fam::Col, } } +/// Families stacked to 100%: each category's values are shares of its total. +fn is_100(f: Fam) -> bool { + matches!(f, Fam::Col100 | Fam::Area100) +} + fn is_col(f: Fam) -> bool { matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) } @@ -900,25 +907,38 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let any_primary = data.series.iter().any(|s| !s.secondary); let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - // Totals for 100% stacking, per axis. - let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]]; + // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). + let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); for (i, s) in data.series.iter().enumerate() { - if fams.get(i) == Some(&Fam::Col100) { - let a = sec.get(i).copied().unwrap_or(false) as usize; + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + if is_100(fam) { + let key = (sec.get(i).copied().unwrap_or(false), fam); + let p = match totals.iter().position(|(k, _)| *k == key) { + Some(p) => p, + None => { + totals.push((key, vec![0.0; ncat])); + totals.len() - 1 + } + }; for (j, v) in s.values.iter().take(ncat).enumerate() { - if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) { + if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { *t += v.abs(); } } } } + // A value's share of its category's total, for the 100% families. + let share = |key: (bool, Fam), j: usize, v: f64| { + let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + }; // Stack accumulators keyed by (axis, family): positive and negative running sums. let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); let mut spans: Vec>> = Vec::with_capacity(data.series.len()); for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); let a = sec.get(i).copied().unwrap_or(false); - let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); let key = (a, fam); let si = if stacked { match stacks.iter().position(|(k, _, _)| *k == key) { @@ -936,12 +956,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let v = s.values.get(j).copied().flatten(); let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { - let v = if fam == Fam::Col100 { - let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0); - if t > 0.0 { v / t } else { 0.0 } - } else { - v - }; + let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; let base = *acc; *acc += v; @@ -949,6 +964,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta }), (Some((_, pos, _)), _) => { let v = v.unwrap_or(0.0); + let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; pos.get_mut(j).map(|acc| { let base = *acc; *acc = clean(*acc + v); @@ -970,7 +986,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta continue; } let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); - let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100)); + let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); let range = value_range(members.iter().flat_map(|i| { let line = fams.get(*i).is_some_and(|f| is_line(*f)); spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) @@ -1044,7 +1060,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); let want = match pass { - 0 => matches!(fam, Fam::Area | Fam::AreaStack), + 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), 1 => is_col(fam), _ => is_line(fam), }; diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index cfe5d5a42..9bddbb270 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl use crate::*; -const ALL_KINDS: [ChartKind; 25] = [ +const ALL_KINDS: [ChartKind; 26] = [ ChartKind::ColumnClustered, ChartKind::ColumnStacked, ChartKind::ColumnStacked100, @@ -19,6 +19,7 @@ const ALL_KINDS: [ChartKind; 25] = [ ChartKind::Doughnut, ChartKind::Area, ChartKind::AreaStacked, + ChartKind::AreaStacked100, ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble, @@ -1073,7 +1074,7 @@ fn pie_points_follow_workbook_theme() { #[test] fn area_charts_draw_source_value_labels_only_when_enabled() { - for kind in [ChartKind::Area, ChartKind::AreaStacked] { + for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] { let d = sample(kind); let mut c = chart(kind); c.data_labels = false; @@ -1097,6 +1098,45 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { } } +#[test] +fn hundred_percent_stacked_areas_fill_each_category_to_its_total() { + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(), + series: vec![ + series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100), + series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100), + ], + }; + let mut c = chart(ChartKind::AreaStacked100); + c.data_labels = false; + let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let t = texts(&p); + assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}"); + assert!(!t.contains(&"40"), "{t:?}"); + let polys: Vec<&Vec<[f32; 2]>> = p + .iter() + .filter_map(|p| match p { + Prim::Polygon { pts, .. } => Some(pts), + _ => None, + }) + .collect(); + let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") }; + // Each polygon is its top edge left to right, then its base right to left. + let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN); + let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN); + let (y0, y100) = (base(north, 0), top(south, 0)); + assert!(y0 > y100, "{y0} {y100}"); + let frac = |y: f32| (y0 - y) / (y0 - y100); + for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] { + assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j))); + assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j))); + } + // A category whose total is zero stays on the baseline. + assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2))); +} + #[test] fn two_level_categories_resolve_and_draw() { let years = ["2023", "", "", "2024", "", ""]; diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8f7c47fb8..721357fec 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -106,6 +106,15 @@ pub fn specs() -> Vec { has_doc, show_levels ), + cmd!( + "data.outlineSettings", + "Outline Settings", + ["Data", "Outline"], + None, + "{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}", + has_doc, + outline_settings + ), cmd!( "data.subtotal", "Subtotal", @@ -1329,6 +1338,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { let (rows, a, b) = outline_lines(s, p)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); let map = if rows { &mut sh.rows } else { &mut sh.cols }; let b = b.min(a.saturating_add(1_048_575)); for i in a..=b { @@ -1339,9 +1349,10 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { } } // A summary line whose group is gone has nothing left to collapse. + let summary = if before { a.checked_sub(1) } else { b.checked_add(1) }; if !on - && !outline::groups(map).iter().any(|g| g.end == b) - && let Some(e) = map.get_mut(&b.saturating_add(1)) + && !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b }) + && let Some(e) = summary.and_then(|i| map.get_mut(&i)) { e.collapsed = false; } @@ -1359,8 +1370,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let all = outline::groups(map); + let all = outline::groups_with(map, before); let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l); let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec { let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); @@ -1369,13 +1381,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { }; let mut chosen = pick(&|g| g.start <= b && g.end >= a); if chosen.is_empty() { - chosen = pick(&|g| g.summary() >= a && g.summary() <= b); + chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b); } for g in chosen { // Inner groups collapsed on their own (their summary marked), before this one changes. let inner: Vec = all .iter() - .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary()) + .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary()) .filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed)) .copied() .collect(); @@ -1384,6 +1396,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { set_hidden(e, hide || inner.iter().any(|o| o.contains(i))); } } + if !g.has_summary() { + continue; + } if hide { map.entry(g.summary()).or_default().collapsed = true; } else if let Some(e) = map.get_mut(&g.summary()) { @@ -1419,15 +1434,16 @@ fn show_levels(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; - for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] { + let before = (outline::summary_before(sh, true), outline::summary_before(sh, false)); + for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] { let Some(n) = n else { continue }; - let groups = outline::groups(map); + let groups = outline::groups_with(map, before); for e in map.values_mut().filter(|e| e.outline > 0) { set_hidden(e, e.outline >= n); } // Inner groups come first, so a summary line shared with the group around it ends up // as that outer group says. - for g in groups { + for g in groups.into_iter().filter(|g| g.has_summary()) { map.entry(g.summary()).or_default().collapsed = g.level >= n; } } @@ -1436,6 +1452,53 @@ fn show_levels(s: &mut Session, p: &Json) -> Result { }) } +/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns +/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. +fn outline_settings(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] { + let Some(on) = bool_param(p, key) else { continue }; + let was_before = outline::summary_before(sh, rows); + if rows { + sh.summary_below = on; + } else { + sh.summary_right = on; + } + if was_before != on { + continue; + } + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + let mut marked: Vec = outline::groups_with(map, was_before) + .into_iter() + .filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)) + .collect(); + // A mark on a line that nested groups share moves with the outermost of them whose + // detail is all hidden, or with the outermost when none is. + let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &marked { + let key = (!g.collapsed, g.level); + let best = owner.entry(g.summary()).or_insert(key); + *best = (*best).min(key); + } + marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); + for g in &marked { + if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + for g in marked { + let g = outline::OutlineGroup { summary_before: !on, ..g }; + if g.has_summary() { + map.entry(g.summary()).or_default().collapsed = true; + } + } + } + Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) + }) +} + fn subtotal(s: &mut Session, p: &Json) -> Result { let (sheet, r) = data_target(s, p, "data.subtotal")?; let header = bool_param(p, "header").unwrap_or(true); @@ -1476,6 +1539,11 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { start = row; } } + // The totals go below each group, so the outline puts summary rows below their detail (the + // Subtotal dialog's "Summary below data"), in the same undo step. + if !sh.summary_below { + s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?; + } // Insert a total row after each group, bottom-up so indices stay valid. let mut inserted = 0; for (g0, g1, name) in groups.iter().rev() { @@ -1540,6 +1608,28 @@ mod tests { assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row"); } + #[test] + fn subtotal_puts_summary_rows_below_their_detail() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.summary_below, "the totals sit below their groups"); + // East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing. + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row); + assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden"); + assert!(!hidden(&s, 3), "East Total stays visible"); + // One undo takes the subtotals and the setting back. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!(!sh.summary_below); + assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West"); + } + #[test] fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { let mut s = Session::new(); @@ -1771,6 +1861,73 @@ mod tests { assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); } + #[test] + fn summary_rows_above_the_detail_take_the_collapsed_mark() { + let mut s = nested_rows(); + let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true})); + // Row 4 (index 3) is the outer group's summary, row 5 the inner one's. + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // Hide Detail on the summary row collapses the group below it. + s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]); + // Ungrouping the outer group clears its mark on the row above. + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap(); + assert_eq!(row_state(&s, 3..=3), [(false, false)]); + } + + #[test] + fn changing_the_summary_direction_moves_collapsed_marks() { + let mut s = nested_rows(); + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap(); + let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + assert!(col_marked(&s, 1) && !col_marked(&s, 5)); + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [false, false, false, false, false, true]); + assert!(col_marked(&s, 1), "columns keep their direction"); + // One undo step per change; no params leaves the settings as they are. + s.execute("edit.undo", json!({})).unwrap(); + assert!(row_state(&s, 3..=3)[0].1); + assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false})); + // Nested groups that end on one row share its mark: it moves to the outer group only. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:8"})).unwrap(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // With only the inner group collapsed, its mark goes to row 5, and the outer group's + // Hide and Show Detail keep it collapsed. + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + // A group on the first row has no row above it to mark. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "1:3"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap(); + assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]); + } + #[test] fn column_sort_rejects_row_key_zero_without_panicking() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index edb0e2d7c..8516b47b1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region), cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row), cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col), - cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to), + cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to), cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special), cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "AutoFill", [], None, - "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)", + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)", has_doc, auto_fill ), @@ -577,26 +577,153 @@ fn select_col(s: &mut Session, _: &Json) -> Result { Ok(json!({"selection": d.selection.a1()})) } +/// How many references the Go To dialog keeps for going back to. +const GO_TO_RECENT: usize = 10; +/// The most areas one Go To selects. +const GO_TO_MAX_AREAS: usize = 2000; +/// How deep a name that refers to other names is followed. +const GO_TO_NAME_DEPTH: usize = 8; + pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "list") == Some(true) { + let d = s.doc()?; + return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent})); + } let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; - // A defined name or table? let d = s.doc()?; - let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() { - let sheet = d.wb.active_sheet; - if let Some(n) = d.wb.name(reference, sheet) { - n.formula.clone() - } else if let Some((si, ti)) = d.wb.table(reference) { - // As in Excel, a table's name stands for its data, without the header and totals rows. - let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default(); - format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t) - } else { + let mut target: Option = None; + let mut areas: Vec = Vec::new(); + let mut named = false; + for part in reference_parts(reference) { + let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else { + return Err(EngineError::Other("Reference isn't valid.".into())); + }; + // One selection holds the areas of one sheet. + if target.is_some_and(|t| t != sheet) { return Err(EngineError::Other("Reference isn't valid.".into())); } - } else { - reference.to_string() + target = Some(sheet); + named |= by_name; + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas."))); + } + } + let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) }; + let range = areas.iter().map(RangeRef::a1).collect::>().join(","); + let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?; + if bool_param(p, "record") == Some(true) && !named { + let d = s.doc_mut()?; + let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default()); + let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::>().join(","); + d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry)); + d.go_to_recent.insert(0, entry); + d.go_to_recent.truncate(GO_TO_RECENT); + } + Ok(out) +} + +/// The parts of a Go To reference, split at the commas outside quoted sheet names and +/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller +/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text. +fn reference_parts(text: &str) -> impl Iterator { + let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false); + let mut chars = text.char_indices(); + std::iter::from_fn(move || { + if done { + return None; + } + for (i, c) in chars.by_ref() { + match c { + '\'' => quoted = !quoted, + '(' if !quoted => depth += 1, + ')' if !quoted => depth = depth.saturating_sub(1), + ',' if !quoted && depth == 0 => { + let part = text.get(start..i).unwrap_or("").trim(); + start = i + 1; + return Some(part); + } + _ => {} + } + } + done = true; + Some(text.get(start..).unwrap_or("").trim()) + }) +} + +/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas, +/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2. +/// A name counts when what it refers to is a reference; a constant or a formula is not one. +fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec, bool)> { + if part.is_empty() || depth > GO_TO_NAME_DEPTH { + return None; + } + let (prefix, body) = split_sheet(part); + let on = match &prefix { + Some(n) => wb.sheet_index(n)?, + None => sheet, }; - let resolved = resolved.replace('$', ""); - selection_set(s, &json!({"range": resolved})) + if let Some(r) = RangeRef::parse(&body.replace('$', "")) { + return Some((on, vec![r], false)); + } + let name = match prefix { + Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && n.name.eq_ignore_ascii_case(body)), + None => wb.name(body, sheet), + }; + if let Some(n) = name { + return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true)); + } + if prefix.is_none() + && let Some((si, ti)) = wb.table(body) + { + // As in Excel, a table's name stands for its data, without the header and totals rows. + let t = wb.sheet(si)?.tables.get(ti)?; + return Some((si, vec![t.data_range().unwrap_or(t.range)], true)); + } + None +} + +/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a +/// reference. +fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec)> { + let from = n.scope.unwrap_or(sheet); + let formula = n.formula.trim().trim_start_matches('='); + let mut target: Option = None; + let mut areas = Vec::new(); + for p in reference_parts(formula) { + let (s, found, _) = go_to_target(wb, from, p, depth + 1)?; + if target.is_some_and(|t| t != s) { + return None; + } + target = Some(s); + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return None; + } + } + target.map(|t| (t, areas)) +} + +/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and +/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`, +/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that +/// do not refer to a range are left out. +pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec { + let own: std::collections::HashSet = wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| n.name.to_ascii_lowercase()).collect(); + let mut out: Vec = wb + .names + .iter() + .filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some()) + .filter_map(|n| match n.scope { + None if own.contains(&n.name.to_ascii_lowercase()) => None, + None => Some(n.name.clone()), + Some(s) if s == sheet => Some(n.name.clone()), + Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)), + }) + .collect(); + out.sort_by_key(|n| n.to_lowercase()); + out.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + out } fn go_to_special(s: &mut Session, p: &Json) -> Result { @@ -1462,6 +1589,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, "extend" => crate::fill::FillMode::Extend, + "days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day), + "weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday), + "months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month), + "years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year), _ => crate::fill::FillMode::Series, }; if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { @@ -1786,6 +1917,119 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + /// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a + /// formula and a hidden name. + fn go_to_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + s.execute("selection.set", json!({"sheet": 0})).unwrap(); + let define = |s: &mut Session, name: &str, refers: &str, scope: &str| { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + }; + define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook"); + define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook"); + define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook"); + define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2"); + define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1"); + define(&mut s, "Rate", "=0.07", "Workbook"); + define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook"); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + wb.names.push(gridcraft_model::DefinedName { + name: "Secret".into(), + scope: None, + formula: "Sheet1!$Z$9".into(), + comment: String::new(), + hidden: true, + }); + s + } + + /// "The Go to popup window shows named ranges on every worksheet in your workbook." Names + /// that are not ranges, and hidden names, are not listed. + #[test] + fn go_to_lists_the_named_ranges_of_every_sheet() { + let mut s = go_to_workbook(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}"); + assert_eq!(list["recent"], json!([]), "{list}"); + // Seen from Sheet2, its own name is bare and Sheet1's is qualified. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); + } + + #[test] + fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap(); + assert_eq!(out["selection"], "C3", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 1); + // A name of several areas selects all of them. + let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap(); + assert_eq!(out["selection"], "A1,C3:C4", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + } + + /// "You can enter multiple named ranges or cell references in the Reference box. Separating + /// each with a comma ... Excel will highlight all the ranges." + #[test] + fn go_to_selects_every_reference_and_name_in_a_list() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap(); + assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap(); + assert_eq!(out["selection"], "A1,B2", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 1); + // Areas on two sheets, and a part that is not a reference, leave the selection alone. + for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] { + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}"); + } + } + + #[test] + fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() { + let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)"); + assert_eq!(parts.next(), Some("A1")); + assert_eq!(parts.next(), Some("'Q1, Q2'!B2")); + assert_eq!(parts.next(), Some("SUM(C3,D4)")); + assert_eq!(parts.next(), None); + assert_eq!(reference_parts("").collect::>(), vec![""]); + let mut s = go_to_workbook(); + let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err(); + assert!(err.to_string().contains("at most"), "{err}"); + let bad = format!("Total{}", ",".repeat(1_000_000)); + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err()); + } + + /// "The Go to box keeps track of ranges as you enter them, and you can return to any of them." + #[test] + fn go_to_keeps_the_references_entered_newest_first() { + let mut s = go_to_workbook(); + s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap(); + // Names are listed already; a call without `record` (the Name Box) is not kept. + s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap(); + s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}"); + // Going back from Sheet2 to a recent reference returns to its sheet. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + for i in 0..GO_TO_RECENT + 2 { + s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap(); + } + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}"); + assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); + } + #[test] fn cell_set_takes_numbers_for_input_and_value() { // #697: a JSON number for `input` used to clear the cell instead of entering it. diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 7396d5356..97bfe36a1 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1270,7 +1270,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result { fn use_in_formula(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; - s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}")))); + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); ok() } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 7718279b2..94a1ce742 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; +use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -55,6 +55,15 @@ pub fn specs() -> Vec { has_doc, create_from_selection ), + cmd!( + "formulas.pasteName", + "Paste Names…", + ["Formulas", "Defined Names"], + Some("F3"), + "{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.", + has_doc, + paste_name + ), 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), @@ -510,6 +519,61 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { }) } +/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the +/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the +/// workbook name it shares. +pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> { + let sheet = wb.active_sheet; + let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet)); + let local: std::collections::HashSet = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); + let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect(); + for n in shown(false) { + if !local.contains(&gridcraft_formula::fold_name(&n.name)) { + out.push((n.name.clone(), format!("={}", n.formula))); + } + } + out.sort_by_cached_key(|(n, _)| n.to_lowercase()); + out +} + +fn paste_name(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let names = pasteable_names(&d.wb); + let listed: Vec = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect(); + if !bool_param(p, "list").unwrap_or(false) { + if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else { + return Err(EngineError::Other(format!("There's no name \"{name}\" to paste."))); + }; + s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone())); + } + return Ok(json!({"names": listed})); + } + if names.is_empty() { + return Err(EngineError::Other("This workbook has no names to paste.".into())); + } + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let rows = u32::try_from(names.len()).unwrap_or(u32::MAX); + if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS { + return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into())); + } + let sheet = d.wb.active_sheet; + let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1)); + edit(s, |cx| { + for (row, (name, refers)) in (at.row..).zip(&names) { + for (col, text) in [(at.col, name), (at.col + 1, refers)] { + let c = CellRef::new(row, col); + let sh = cx.sheet_mut(sheet)?; + let style = sh.style_id(c); + // The second column holds the refers-to text as a text value, with no formula. + sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style }); + cx.touch(sheet, c); + } + } + Ok(json!({"names": listed, "range": range.a1()})) + }) +} + fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); @@ -664,8 +728,12 @@ fn error_checking(s: &mut Session, _: &Json) -> Result { } } } - let circ: Vec = - d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect(); + let circ: Vec = d + .calc + .circular + .iter() + .map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1())) + .collect(); Ok(json!({"errors": out, "circular": circ})) } @@ -877,4 +945,92 @@ mod tests { // The union parses as one, so the preview is what the union gives in a cell, not #NAME?. assert_eq!(u["value"], json!({"error": "#VALUE!"})); } + + /// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook + /// `Tax`, and a name of another sheet. + fn named_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap(); + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let name = |name: &str, scope, formula: &str, hidden| DefinedName { + name: name.into(), + scope, + formula: formula.into(), + comment: String::new(), + hidden, + }; + wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true)); + wb.names.push(name("TAX", Some(0), "0.25", false)); + wb.names.push(name("Other", Some(1), "1", false)); + s + } + + fn text_at(s: &Session, cell: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) + } + + #[test] + fn paste_names_lists_the_names_the_active_sheet_can_use() { + let mut s = named_workbook(); + let r = s.execute("formulas.pasteName", json!({})).unwrap(); + assert_eq!( + r["names"], + json!([ + {"name": "rate", "refersTo": "=0.07"}, + {"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"}, + {"name": "TAX", "refersTo": "=0.25"}, + ]) + ); + assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing"); + } + + #[test] + fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() { + let mut s = named_workbook(); + s.take_ui_requests(); + s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap(); + s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap(); + assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]); + assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered"); + // A name Paste Name doesn't list is refused; one typed in another case is pasted as listed. + assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err()); + s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap(); + assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]); + } + + #[test] + fn paste_list_writes_each_name_and_what_it_refers_to() { + let mut s = named_workbook(); + s.execute("selection.set", json!({"range": "D2"})).unwrap(); + let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap(); + assert_eq!(r["range"], json!("D2:E4")); + let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")]; + for (cell, text) in rows { + assert_eq!(text_at(&s, cell), Value::text(text), "{cell}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text"); + s.execute("edit.undo", json!({})).unwrap(); + for (cell, _) in rows { + assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo"); + } + s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap(); + assert_eq!(text_at(&s, "B7"), Value::text("rate")); + } + + #[test] + fn paste_list_needs_names_and_room() { + let mut s = named_workbook(); + let last = format!("A{}", gridcraft_core::MAX_ROWS - 1); + assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row"); + let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1)); + assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column"); + let mut empty = Session::new(); + empty.new_workbook(); + assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err()); + assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([])); + } } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2388d1f25..3c45031af 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Chart Properties", ["Chart Design"], None, - "{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", + "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", has_doc, chart_set ), @@ -582,6 +582,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind { ("pie", _) => ChartKind::Pie, ("doughnut", _) => ChartKind::Doughnut, ("area", "stacked") => ChartKind::AreaStacked, + ("area", "stacked100") => ChartKind::AreaStacked100, ("area", _) => ChartKind::Area, ("scatter", "lines") => ChartKind::ScatterLines, ("scatter", _) => ChartKind::Scatter, diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 229ff22b6..df48e9f59 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -184,10 +184,19 @@ pub fn specs() -> Vec { "Show Details", [], None, - "{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}", + "{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).", has_doc, show_details ), + cmd!( + "pivot.options", + "PivotTable Options", + ["PivotTable Analyze", "PivotTable"], + None, + "{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}", + has_doc, + options + ), cmd!( "pivot.delete", "Delete PivotTable", @@ -755,8 +764,13 @@ fn show_details(s: &mut Session, p: &Json) -> Result { .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; - let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + // With the option off, a report that is out of date or a source that cannot be read is + // reported as the option; only a cell that is not a value cell keeps its own message. + if !pt.enable_show_details && !matches!(found, Ok(None)) { + return Err(other(SHOW_DETAILS_OFF)); + } + let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; if d.wb.protected_structure { return Err(other("The workbook is protected and cannot be changed.")); } @@ -824,6 +838,32 @@ fn show_details(s: &mut Session, p: &Json) -> Result { Ok(res) } +/// The message Show Details gives while Enable show details is off. +const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable."; + +/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not +/// refreshed. Without params it returns the current setting. +fn options(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let enable = bool_param(p, "enableShowDetails"); + if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { + return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); + } + let current = |s: &Session| -> Result { + let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + Ok(json!({"enableShowDetails": pt.enable_show_details})) + }; + let Some(on) = enable else { return current(s) }; + if current(s)?["enableShowDetails"] == json!(on) { + return current(s); + } + edit(s, |cx| { + let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; + pt.enable_show_details = on; + Ok(json!({"enableShowDetails": on})) + }) +} + fn summarize_table(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let name = match str_param(p, "table") { @@ -861,6 +901,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), "valuesOnRows": pt.values_on_rows, "valuesPosition": pt.values_position, + "enableShowDetails": pt.enable_show_details, }) } @@ -2335,6 +2376,7 @@ mod tests { "pivot.changeSource", "pivot.delete", "pivot.showDetails", + "pivot.options", ]; let hostile = [ json!({}), @@ -2580,4 +2622,72 @@ mod tests { } assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); } + + #[test] + fn enable_show_details_off_stops_show_details() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert!(!pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert_eq!(e, "Show Details is turned off for this PivotTable."); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // A label still reports that it is not a value cell. + let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); + assert!(!e.contains("turned off"), "{e}"); + // Setting the same value again adds no undo step. + let after = s.doc().unwrap().revision; + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert_eq!(s.doc().unwrap().revision, after); + assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); + // One undo turns the option back on. + s.execute("edit.undo", json!({})).unwrap(); + assert!(pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); + } + + #[test] + fn enable_show_details_off_is_reported_before_an_out_of_date_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + // The source changes after the report was written, so the report is out of date. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } + + #[test] + fn detail_cells_are_the_cells_show_details_takes() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for layout in 0..2 { + if layout == 1 { + // A two-level row axis, with subtotals at the top of each group. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + } + let d = s.doc().unwrap(); + let si = d.wb.active_sheet; + let pt = &d.wb.active().unwrap().pivots[0]; + let r = pt.last_range.unwrap(); + let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap(); + let mut values = 0; + for row in 0..r.height() { + for col in 0..r.width() { + let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap(); + assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})"); + values += usize::from(one.is_some()); + } + } + assert!(values > 4, "layout {layout} has value cells"); + } + // An out-of-date report answers as Show Details does. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let d = s.doc().unwrap(); + let pt = &d.wb.active().unwrap().pivots[0]; + assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet).unwrap_err().contains("out of date")); + } } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 1c91518e2..52c975688 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -139,8 +139,26 @@ impl SheetLayout { let [l, r, t, b, ..] = self.margins; ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) } + /// Left and right margins, in points, that header and footer sections line up with: the + /// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at + /// most a quarter of the page width. + pub(crate) fn hf_sides(&self) -> (f32, f32) { + if self.settings.hf_align_with_margins { + (self.margins[0], self.margins[1]) + } else { + let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0); + (side, side) + } + } + /// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1. + pub(crate) fn hf_text_scale(&self) -> f32 { + if self.settings.hf_scale_with_doc { self.scale } else { 1.0 } + } } +/// Side margin of headers and footers, in points, when Align with Page Margins is off. +const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0; + /// Options shared by the print commands. #[derive(Clone, Debug, Default)] pub struct PrintOptions { @@ -1476,7 +1494,9 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: } fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { - let [l, r, _, _, hm, fm] = lay.margins; + let [_, _, _, _, hm, fm] = lay.margins; + let (l, r) = lay.hf_sides(); + let text_scale = lay.hf_text_scale(); let pw = lay.page_w; let ph = lay.page_h; for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] { @@ -1488,7 +1508,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model:: if sec.text.trim().is_empty() { continue; } - let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0); + let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale; let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic); let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); let n = lines.len() as f32; @@ -2021,6 +2041,40 @@ mod tests { assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); } + /// The x position and font size of the first `(text) Tj` in a PDF. + fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { + let s = String::from_utf8_lossy(pdf); + let before = &s[..s.find(&format!("({text}) Tj"))?]; + let tf = before.rfind(" Tf")?; + let size = before[..tf].rsplit(' ').next()?.parse().ok()?; + let tm = before.rfind(" Tm")?; + let mut nums = before[..tm].rsplit([' ', '\n']); + let _y = nums.next()?; + let x = nums.next()?.parse().ok()?; + Some((x, size)) + } + + #[test] + fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap(); + // Both on (the default): the text prints at half size, from the 1.5-inch left margin. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0)); + let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap(); + assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false))); + // Both off: full size, 0.7 inch from the paper's left edge. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0)); + s.execute("edit.undo", json!({})).unwrap(); + let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print; + assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins); + } + #[test] fn pdf_prints_cjk_text_instead_of_question_marks() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b42aec9a6..dd9033d04 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -44,6 +44,15 @@ pub fn specs() -> Vec { s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar); Ok(json!({"on": s.prefs.formula_bar})) }), + cmd!( + query "view.statusBar", + "Customize Status Bar", + [], + None, + "{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}", + always, + status_bar + ), 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!( @@ -115,7 +124,7 @@ pub fn specs() -> Vec { "Header & Footer", ["Insert", "Text"], None, - "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}", + "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}", has_doc, header_footer ), @@ -187,6 +196,30 @@ fn show_objects(s: &mut Session, p: &Json) -> Result { }) } +fn status_bar(s: &mut Session, p: &Json) -> Result { + let st = &mut s.prefs.status_bar; + for (key, flag) in [ + ("average", &mut st.average), + ("count", &mut st.count), + ("numericalCount", &mut st.numerical_count), + ("minimum", &mut st.minimum), + ("maximum", &mut st.maximum), + ("sum", &mut st.sum), + ] { + if let Some(on) = bool_param(p, key) { + *flag = on; + } + } + Ok(json!({ + "average": st.average, + "count": st.count, + "numericalCount": st.numerical_count, + "minimum": st.minimum, + "maximum": st.maximum, + "sum": st.sum, + })) +} + fn zoom(s: &mut Session, p: &Json) -> Result { let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16; let d = s.doc_mut()?; @@ -392,6 +425,12 @@ fn header_footer(s: &mut Session, p: &Json) -> Result { if let Some(on) = bool_param(p, "differentOddEven") { pr.different_odd_even = on; } + if let Some(on) = bool_param(p, "scaleWithDoc") { + pr.hf_scale_with_doc = on; + } + if let Some(on) = bool_param(p, "alignWithMargins") { + pr.hf_align_with_margins = on; + } if let Some((page, footer, idx, text)) = §ion { let spec = pr.hf_mut(*page, *footer); let mut parts = super::print::split_header_footer(spec); @@ -405,6 +444,8 @@ fn header_footer(s: &mut Session, p: &Json) -> Result { "footer": pr.footer, "differentFirst": pr.different_first, "differentOddEven": pr.different_odd_even, + "scaleWithDoc": pr.hf_scale_with_doc, + "alignWithMargins": pr.hf_align_with_margins, "firstHeader": pr.first_header, "firstFooter": pr.first_footer, "evenHeader": pr.even_header, @@ -478,4 +519,22 @@ mod tests { let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap(); assert!(old.formula_bar); } + + #[test] + fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() { + let mut s = Session::new(); + let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true}); + assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all); + let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap(); + assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false})); + assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum); + // A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest. + let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap(); + assert_eq!(old.status_bar, crate::StatusBarStats::default()); + let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap(); + assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["statusBar"]["minimum"], true); + assert_eq!(saved["statusBar"]["numericalCount"], false); + } } diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 9ff8eac31..a3815fe36 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd}; use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::{Cell, Formula}; +use gridcraft_model::{Cell, Formula, Workbook}; use gridcraft_numfmt::{FormatKind, NumberFormat}; use crate::Result; @@ -23,15 +23,43 @@ pub enum FillMode { ValuesOnly, /// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up. Extend, + /// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit. + /// A lone date steps by one unit; two or more step by the difference between the first two, + /// counted in that unit (one unit when they fall in the same one). A source that isn't dates + /// extends like a plain drag. + Dates(DateUnit), +} + +/// The step of a [`FillMode::Dates`] fill. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateUnit { + Day, + /// Monday to Friday: Saturday and Sunday are skipped. + Weekday, + Month, + Year, } impl FillMode { /// The modes that continue the source's series rather than copy it. fn extends_series(self) -> bool { - matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly) + matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_)) } } +/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"` +/// and `h:mm` don't). +fn is_date_format(fmt: &str) -> bool { + NumberFormat::parse(fmt).kind() == FormatKind::Date +} + +/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays, +/// months or years: a number (not a formula) shown in a date format. +pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool { + let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false }; + cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str()) +} + /// Built-in custom lists. fn lists() -> Vec> { let short_days: Vec = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect(); @@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F }; let fmt = |c: &(CellRef, Option)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str(); let pattern = if mode.extends_series() || mode == FillMode::Alternate { - let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default()); + let first_fmt = src_cells.first().map(fmt).unwrap_or_default(); + let dates = match mode { + FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt), + _ => None, + }; + let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt)); match (mode, detected) { (FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells), (_, p) => p, @@ -130,7 +163,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F writes.push((*t, Some(existing))); continue; } - FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => { + FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => { let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style }; if let Some(f) = &cell.formula { if let Some(e) = f.expr() { @@ -204,9 +237,63 @@ enum Pattern { months: i64, sys: gridcraft_core::DateSystem, }, + /// Dates stepping by weekdays, skipping Saturday and Sunday. + Weekdays { + last: f64, + first: f64, + step: i64, + sys: gridcraft_core::DateSystem, + }, } impl Pattern { + /// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the + /// source isn't all dates (numbers, not formulas, under a date format in its first cell). + fn dates(cells: &[(CellRef, Option)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option { + if !is_date_format(fmt) { + return None; + } + let mut nums = Vec::with_capacity(cells.len()); + for (_, cell) in cells { + let cell = cell.as_ref()?; + if cell.formula.is_some() || !cell.value.is_number() { + return None; + } + nums.push(cell.value.as_f64()?); + } + let first = *nums.first()?; + let last = *nums.last()?; + let second = nums.get(1).copied(); + // The difference between the first two dates in the unit, and one unit when they fall in + // the same one. + let at_least_one = |n: i64| if n == 0 { 1 } else { n }; + Some(match unit { + DateUnit::Day => { + let days = second.map_or(1.0, |b| b.floor() - first.floor()); + Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } } + } + DateUnit::Weekday => { + let step = match second { + Some(b) => at_least_one(weekdays_between(sys, first, b)?), + None => 1, + }; + Pattern::Weekdays { last, first, step, sys } + } + DateUnit::Month | DateUnit::Year => { + let months = match second { + Some(b) => { + let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?); + let years = b.year as i64 - a.year as i64; + if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) } + } + None if unit == DateUnit::Year => 12, + None => 1, + }; + Pattern::Months { last, first, months, sys } + } + }) + } + fn detect(cells: &[(CellRef, Option)], custom: &[Vec], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern { let vals: Vec = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect(); if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) { @@ -343,20 +430,72 @@ impl Pattern { let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?; Some(Value::number(s)) } + Pattern::Weekdays { last, first, step, sys } => { + let base = if forward { *last } else { *first }; + Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?)) + } } } } +/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range. +fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option { + datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6) +} + +/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on +/// a Saturday or Sunday moves to the next (or previous) weekday for the first step. +fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option { + if n == 0 { + return Some(serial); + } + let dir = n.signum() as f64; + let mut d = serial; + let next_weekday = |d: &mut f64| -> Option<()> { + loop { + *d += dir; + if is_weekday(sys, *d)? { + return Some(()); + } + } + }; + next_weekday(&mut d)?; + // From a weekday, every seven days hold five weekdays. + let rest = n.unsigned_abs() - 1; + d += dir * (rest / 5 * 7) as f64; + for _ in 0..rest % 5 { + next_weekday(&mut d)?; + } + datetime_from_serial(sys, d)?; + Some(d) +} + +/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first), +/// counting whole days; `None` outside the supported date range. +fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option { + let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) }; + let days = (hi - lo) as i64; + let mut count = days / 7 * 5; + let mut d = lo + (days / 7 * 7) as f64; + for _ in 0..days % 7 { + d += 1.0; + if is_weekday(sys, d)? { + count += 1; + } + } + Some(count * sign) +} + /// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the /// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date, -/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. +/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept. fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option { let d = datetime_from_serial(sys, serial)?; let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?; let year = total.div_euclid(12); let month = total.rem_euclid(12) + 1; let last = days_in_month(i32::try_from(year).ok()?, month as u32); - serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)) + serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor()) } fn round15(x: f64) -> f64 { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index be47e0a67..92ab69038 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -770,7 +770,7 @@ mod tests { fn save_fails_upfront_when_the_temp_name_is_taken() { // Another local user who can write the directory must not be able to point the // save's predictable temporary name at their own file: creation is exclusive. - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let victim = dir.join("victim.txt"); @@ -788,7 +788,7 @@ mod tests { #[test] fn save_keeps_the_destination_access_restrictions() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let dest = dir.join("private.xlsx"); @@ -808,7 +808,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn open_refuses_directories_and_unknowable_lengths() { - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4ab60f3a..9b5410a2f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -199,6 +199,9 @@ pub struct DocState { /// workbook is opened from a file, carried by content pasted from such a workbook, and never /// changed by a trust decision. pub trust: Trust, + /// The references `edit.goTo` with `record: true` went to in this document, newest first, + /// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`). + pub go_to_recent: Vec, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -285,6 +288,7 @@ impl DocState { content_kind: None, scripts_trusted, trust, + go_to_recent: vec![], } } pub fn is_dirty(&self) -> bool { @@ -347,6 +351,27 @@ pub struct Prefs { pub user_dictionary: Vec, /// View › Show › Formula Bar (an application option in Excel, not saved in the file). pub formula_bar: bool, + /// The statistics the status bar shows for a selection (right-click the status bar). + pub status_bar: StatusBarStats, +} + +/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on +/// Average, Count and Sum by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct StatusBarStats { + pub average: bool, + pub count: bool, + pub numerical_count: bool, + pub minimum: bool, + pub maximum: bool, + pub sum: bool, +} + +impl Default for StatusBarStats { + fn default() -> Self { + StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true } + } } impl Default for Prefs { @@ -361,6 +386,7 @@ impl Default for Prefs { r1c1: false, user_dictionary: vec![], formula_bar: true, + status_bar: StatusBarStats::default(), } } } @@ -372,6 +398,9 @@ pub enum UiRequest { Message(String), /// Start editing the active cell (F2) with optional initial text. EditCell(Option), + /// Put a defined name at the caret of the cell being edited, in place of selected text; with + /// no edit under way, start editing the active cell with `=` and the name. + InsertName(String), OpenUrl(String), } diff --git a/crates/engine/src/outline.rs b/crates/engine/src/outline.rs index 80d14afe2..a3ca782c9 100644 --- a/crates/engine/src/outline.rs +++ b/crates/engine/src/outline.rs @@ -1,17 +1,18 @@ //! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above //! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the //! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary -//! columns right of it (Excel's defaults; the model has no setting for the other way round). +//! columns right of it, or above / left of it when the sheet's outline settings say so +//! ([`Sheet::summary_below`], [`Sheet::summary_right`]). use std::collections::BTreeMap; -use gridcraft_model::LineInfo; +use gridcraft_model::{LineInfo, Sheet}; /// The deepest outline level XLSX allows. pub const MAX_LEVEL: u8 = 7; /// One group: lines `start..=end` at outline level `level` or deeper, with its summary line -/// just after `end`. +/// just after `end`, or just before `start` when `summary_before` is set. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OutlineGroup { pub level: u8, @@ -19,25 +20,43 @@ pub struct OutlineGroup { pub end: u32, /// Every detail line is hidden: the button shows +. pub collapsed: bool, + /// The summary line is above (rows) or left of (columns) the detail. + pub summary_before: bool, } impl OutlineGroup { /// The summary line, where the − / + button sits. pub fn summary(&self) -> u32 { - self.end.saturating_add(1) + if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) } + } + /// Whether the summary line exists: a group that starts at the first line (summary before) + /// or ends at the last one (summary after) has none. + pub fn has_summary(&self) -> bool { + !self.contains(self.summary()) } pub fn contains(&self, line: u32) -> bool { line >= self.start && line <= self.end } } +/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail. +pub fn summary_before(sh: &Sheet, rows: bool) -> bool { + if rows { !sh.summary_below } else { !sh.summary_right } +} + /// The deepest outline level used by these lines (0 = no outline). pub fn max_level(lines: &BTreeMap) -> u8 { lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0) } -/// Every group, ordered by where it ends (inner groups before the group around them). +/// Every group, with its summary line after the detail; see [`groups_with`]. pub fn groups(lines: &BTreeMap) -> Vec { + groups_with(lines, false) +} + +/// Every group, ordered by where it ends (inner groups before the group around them), with its +/// summary line before the detail when `summary_before` is set. +pub fn groups_with(lines: &BTreeMap, summary_before: bool) -> Vec { let mut out = Vec::new(); // Open group per level (index level - 1): start line and whether all its lines are hidden. let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize]; @@ -47,7 +66,7 @@ pub fn groups(lines: &BTreeMap) -> Vec { if let Some(slot) = open.get_mut(level as usize - 1) && let Some((start, hidden)) = slot.take() { - out.push(OutlineGroup { level, start, end, collapsed: hidden }); + out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before }); } } }; @@ -102,9 +121,9 @@ mod tests { assert_eq!( g, vec![ - OutlineGroup { level: 2, start: 5, end: 6, collapsed: false }, - OutlineGroup { level: 1, start: 4, end: 7, collapsed: false }, - OutlineGroup { level: 1, start: 11, end: 12, collapsed: true }, + OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false }, + OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false }, + OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false }, ] ); assert_eq!(g[1].summary(), 8); @@ -129,5 +148,22 @@ mod tests { assert_eq!(g.len(), 7); assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX)); assert_eq!(g[0].summary(), u32::MAX); + assert!(!g[0].has_summary()); + } + + #[test] + fn summary_before_the_detail() { + // Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5. + let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]); + let g = groups_with(&l, true); + let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied(); + assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true))); + assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true))); + // A group on the first line has no line before it. + assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false))); + let mut sh = Sheet::new("S"); + assert!(!summary_before(&sh, true) && !summary_before(&sh, false)); + sh.summary_below = false; + assert!(summary_before(&sh, true) && !summary_before(&sh, false)); } } diff --git a/crates/engine/src/paper.rs b/crates/engine/src/paper.rs index 01065c09e..0800f4fc7 100644 --- a/crates/engine/src/paper.rs +++ b/crates/engine/src/paper.rs @@ -29,6 +29,12 @@ pub struct PageGrid { pub gap: f64, /// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`. pub scale: f32, + /// Left and right margins of headers and footers in layout units (the side margins, or a + /// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off). + pub hf_sides: [f64; 2], + /// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on, + /// otherwise `1 / scale`, so the text keeps its point size on paper. + pub hf_text_k: f32, /// Paper size in points (after orientation), for the rulers. pub paper_pt: (f32, f32), pub row_bands: Vec<(u32, u32)>, @@ -65,6 +71,8 @@ impl PageGrid { let s = f64::from(lay.scale.clamp(0.1, 4.0)); let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s; let margins = lay.margins.map(to_layout); + let (hf_left, hf_right) = lay.hf_sides(); + let hf_text_k = lay.hf_text_scale() / s as f32; let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| { let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0); let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first); @@ -79,6 +87,8 @@ impl PageGrid { margins, gap: f64::from(PAGE_GAP) / s, scale: lay.scale, + hf_sides: [to_layout(hf_left), to_layout(hf_right)], + hf_text_k, paper_pt: (lay.page_w, lay.page_h), rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1), cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1), @@ -174,11 +184,12 @@ impl PageGrid { } /// The header or footer section under the point: the top or bottom margin of a page, split - /// into thirds between the side margins. + /// into thirds between the header and footer side margins. pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> { let (i, j) = self.page_at(x, y)?; let (px, py, _, _) = self.page_rect(i, j); - let [l, r, t, b, ..] = self.margins; + let [_, _, t, b, ..] = self.margins; + let [l, r] = self.hf_sides; let footer = if y < py + t { false } else if y >= py + self.page_h - b { @@ -301,6 +312,31 @@ mod tests { assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0)); } + #[test] + fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() { + let mut wb = wb_with(10, 3); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.scale = 50; + sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3]; + } + let g = PageGrid::build(&wb, 0, 50, 20).unwrap(); + let (px, py, _, _) = g.page_rect(0, 0); + // Both on: the sections sit between the 2-inch side margins and the text scales with the cells. + assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]); + assert!((g.hf_text_k - 1.0).abs() < 1e-6); + assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2)); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.hf_scale_with_doc = false; + sh.print.hf_align_with_margins = false; + } + let g = PageGrid::build(&wb, 0, 50, 20).unwrap(); + // Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the + // text keeps its point size on paper. + assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides); + assert!((g.hf_text_k - 2.0).abs() < 1e-6); + assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1)); + } + #[test] fn print_scale_and_hostile_settings_stay_bounded() { let mut wb = wb_with(3, 3); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 397dd5ad2..a490824da 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -816,11 +816,30 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; /// on the sheet is no longer what the source computes: the cell's records would be another cell's. pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { let src = read_source(wb, &pt.source, pivot_sheet)?; - let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; + let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None }; let out = compute_from(wb, pt, &src, Some(&mut probe))?; - // Excel reads its PivotTable cache; without one, the report written by the last refresh - // must still be what the source gives. Checked first: in a stale report even a cell that - // computes as a label may show a value. + // Checked first: in a stale report even a cell that computes as a label may show a value. + check_current(wb, pt, pivot_sheet, &out)?; + let Some((rp, cp)) = probe.hit else { return Ok(None) }; + let records = + probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); + Ok(Some((src, records))) +} + +/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds +/// records: its value, subtotal and grand total cells, from one computation of the report. The +/// same errors as [`detail_records`]. +pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) }; + let out = compute_from(wb, pt, &src, Some(&mut probe))?; + check_current(wb, pt, pivot_sheet, &out)?; + Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect()) +} + +/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable +/// cache; without one, the report written by the last refresh must still be what the source gives. +fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> { let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; let current = pt.last_range.is_some_and(|r| { r.height() == out.height().max(1) @@ -834,17 +853,17 @@ pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u if !current { return Err("The PivotTable report is out of date. Refresh it first.".into()); } - let Some((rp, cp)) = probe.hit else { return Ok(None) }; - let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); - Ok(Some((src, records))) + Ok(()) } -/// What [`detail_records`] asks of a report computation: the row and column item paths of the -/// value cell at `at`, and the paths of every record the filters let through. +/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column +/// item paths of the value cell at `at`, the paths of every record the filters let through (when +/// `paths` is set), and every value cell (when `cells` is set). struct Probe { - at: (usize, usize), + at: Option<(usize, usize)>, hit: Option<(Vec, Vec)>, - paths: Vec<(usize, Vec, Vec)>, + paths: Option, Vec)>>, + cells: Option>, } fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { @@ -959,8 +978,8 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option< if !col_leaves.contains(&cp[..shown]) { col_leaves.insert(cp[..shown].to_vec()); } - if let Some(pr) = probe.as_deref_mut() { - pr.paths.push((ri, rp.clone(), cp.clone())); + if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) { + paths.push((ri, rp.clone(), cp.clone())); } row_set.insert(rp); col_set.insert(cp); @@ -1326,10 +1345,13 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option< _ => raw, }, }; - if let Some(pr) = probe.as_deref_mut() - && pr.at == (r, l + j) - { - pr.hit = Some((rp.to_vec(), cp.to_vec())); + if let Some(pr) = probe.as_deref_mut() { + if pr.at == Some((r, l + j)) { + pr.hit = Some((rp.to_vec(), cp.to_vec())); + } + if let Some(cells) = pr.cells.as_mut() { + cells.insert((r, l + j)); + } } g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f4c47bb62..3c9e6f65e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -941,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() { assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]); } +#[test] +fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() { + let mut s = s(); + let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::>(); + let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::>(); + // Friday 1/30/2026 is serial 46052. + s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date"); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap(); + assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0])); + // Fill Weekdays skips Saturday 1/31 and Sunday 2/1. + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0])); + // From Saturday 1/31, the first weekday is Monday 2/2. + s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0])); + // Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30. + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap(); + assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0])); + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap(); + assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0])); + // Two dates step by their difference in the unit: 1/15 and 3/15 by two months. + s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap(); + assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0])); + // Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10. + s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap(); + s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0])); + // Upward from Monday 2/2: Friday 1/30, Thursday 1/29. + s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0])); + // A number that isn't a date fills as a plain drag does: a lone number copies. + s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap(); + assert_eq!(v(&s, "F2"), Value::Number(7.0)); + // An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months. + s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap(); + assert_eq!(v(&s, "G2"), Value::Number(46081.0)); + // A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates + // a month apart, and the Series dialog by months. + s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap(); + assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time"); + s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap(); + assert_eq!(v(&s, "H2"), Value::Number(46081.5)); + s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap(); + assert_eq!(v(&s, "H2"), Value::Number(46055.5)); + s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap(); + s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap(); + assert_eq!(v(&s, "I3"), Value::Number(46096.25)); + s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap(); + s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap(); + assert_eq!(v(&s, "J2"), Value::Number(46081.5)); +} + +#[test] +fn only_a_number_in_a_date_format_offers_the_date_fills() { + let mut s = s(); + let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap()); + s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap(); + assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates"); + assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field"); + assert!(!dates(&s, "A4"), "a formula is not offered"); + assert!(!dates(&s, "A5"), "a time is not a date"); + assert!(!dates(&s, "A6"), "an empty cell is not a date"); +} + #[test] fn insert_delete_cells_adjust() { let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); @@ -2784,6 +2864,42 @@ fn calculation_options_enable_iterative_calculation() { near(&s, "A1", 10.0); } +#[test] +fn error_checking_quotes_sheet_names_in_circular_references() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap(); + let r = s.execute("formulas.errorChecking", json!({})).unwrap(); + assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"])); + // The reference reads back in a formula and in Go To. + let reference = r["circular"][0].as_str().unwrap().to_string(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(2.0)); + s.execute("edit.goTo", json!({"reference": reference})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string())); +} + +/// Undo and redo put cells back without calculating them; the circular references listed are +/// those of the restored cells. +#[test] +fn undo_and_redo_list_the_circular_references_of_the_restored_cells() { + let mut s = s(); + let listed = |s: &Session| s.doc().unwrap().calc.circular.iter().map(|(si, c)| (*si, c.a1())).collect::>(); + s.execute("cell.set", json!({"cell": "A5", "input": "=A5+1"})).unwrap(); + assert_eq!(listed(&s), vec![(0, "A5".to_string())]); + s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); + assert_eq!(listed(&s), vec![(0, "A6".to_string())]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(listed(&s), vec![(0, "A5".to_string())]); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(listed(&s), vec![(0, "A6".to_string())]); + // Undoing the loop itself lists none. + s.execute("edit.undo", json!({"steps": 2})).unwrap(); + assert!(listed(&s).is_empty()); +} + fn chart_data(s: &mut Session) { s.execute( "range.setValues", diff --git a/crates/engine/tests/it/chart_area_stacked100.rs b/crates/engine/tests/it/chart_area_stacked100.rs new file mode 100644 index 000000000..2d0d0c21d --- /dev/null +++ b/crates/engine/tests/it/chart_area_stacked100.rs @@ -0,0 +1,35 @@ +//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype +//! `stacked100`. + +use gridcraft_engine::Session; +use gridcraft_model::ChartKind; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap(); + s +} + +fn kinds(s: &Session) -> Vec { + s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect() +} + +#[test] +fn insert_chart_makes_a_hundred_percent_stacked_area() { + let mut s = session(); + s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked100]); +} + +#[test] +fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() { + let mut s = session(); + s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked]); + s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked100]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 385ec76ca..924a95d1b 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod autofit_filter_buttons; mod cf_formula_validate; +mod chart_area_stacked100; mod chart_workbook_names; mod command_sequence_fuzz; mod cross_sheet_cut; diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 9bf43d6f3..7cae66aac 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -23,18 +23,20 @@ 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) + a == b || fold_name(a) == fold_name(b) +} + +/// The case-folded form of a name: two names are [`same_name`] when their folded forms are equal. +pub fn fold_name(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() } #[cfg(test)] diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index aa250b9b0..00db5e879 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -410,6 +410,7 @@ pub enum ChartKind { Doughnut, Area, AreaStacked, + AreaStacked100, Scatter, ScatterLines, Bubble, @@ -1044,6 +1045,9 @@ pub struct PivotTable { pub values: Vec, pub filters: Vec, pub layout: PivotLayout, + /// PivotTable Options › Data › Enable show details: Show Details lists a value cell's + /// source records only while this is on. + pub enable_show_details: bool, /// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows". pub grand_totals_rows: bool, /// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns". @@ -1087,6 +1091,7 @@ impl Default for PivotTable { values: vec![], filters: vec![], layout: PivotLayout::Compact, + enable_show_details: true, grand_totals_rows: true, grand_totals_cols: true, subtotals: true, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index f7ef8f3be..b43bfa525 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -80,6 +80,13 @@ pub struct Sheet { /// outline itself stays. #[serde(default = "yes")] pub show_outline_symbols: bool, + /// Outline summary rows sit below their detail, and summary columns right of it (Data ▸ + /// Outline ▸ Settings; off puts them above / left). Saved as `outlinePr summaryBelow` and + /// `summaryRight`. + #[serde(default = "yes")] + pub summary_below: bool, + #[serde(default = "yes")] + pub summary_right: bool, pub right_to_left: bool, pub zoom: u16, pub cond_formats: Vec, @@ -149,6 +156,8 @@ impl Sheet { visibility: Visibility::Visible, show_gridlines: true, show_outline_symbols: true, + summary_below: true, + summary_right: true, show_headings: true, show_formulas: false, show_zeros: true, diff --git a/crates/ui-egui/src/cell_pictures.rs b/crates/ui-egui/src/cell_pictures.rs index 4930e7b97..2a8d4ff22 100644 --- a/crates/ui-egui/src/cell_pictures.rs +++ b/crates/ui-egui/src/cell_pictures.rs @@ -130,7 +130,12 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc) { painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } else { - crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130)); + crate::icons::paint_on_sheet( + &painter, + cell.shrink(2.0), + crate::icons::Icon::Picture, + crate::theme::Tokens::get(painter.ctx()).sheet_control_border, + ); } } diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index fad08ff22..fb3dbd8dd 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -8,7 +8,7 @@ use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook}; use crate::SheetApp; use crate::grid::Geo; -use crate::theme; +use crate::theme::{self, Tokens}; fn rgba(c: [u8; 4]) -> Color32 { Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) @@ -310,7 +310,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } ShapeKind::Icon => { if let Some(icon) = crate::icons::from_name(&sp.text) { - crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill }); + let t = Tokens::get(p.ctx()); + crate::icons::paint_on_sheet(p, r, icon, if fill == Color32::TRANSPARENT { t.cell_text } else { fill }); } label = false; } @@ -341,7 +342,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) { p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } else { - p.rect_filled(r, 0.0, Color32::from_gray(230)); + p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track); } if app.selected_chart == Some(im.id) { selection_frame(p, r); @@ -356,7 +357,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo let start = p.add(egui::Shape::Noop); // Soft shadow + white card. p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18)); - p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg); let data = gridcraft_chart::resolve(wb, si, ch); let m = EguiMeasure { painter: p, z: geo.z }; let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m); @@ -373,9 +374,10 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) { let rr = geo.range_rect(sh, rf.range()); + let color = Tokens::get(p.ctx()).ref_color(0, true); for pane in geo.range_panes(rf.range()) { let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); - pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside); } } } @@ -447,7 +449,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz if !r.contains(tr.left_top()) { continue; // overflowed the card; skip (no scroll yet) } - let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) }; p.rect_filled(*tr, 3.0 * geo.z, fill); let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); @@ -491,10 +493,11 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> } fn selection_frame(p: &Painter, r: Rect) { - p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); + let t = Tokens::get(p.ctx()); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside); for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] { - p.circle_filled(c, 4.0, Color32::WHITE); - p.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::from_gray(100))); + p.circle_filled(c, 4.0, t.sheet_control_bg); + p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border)); } let _ = Align2::CENTER_CENTER; } diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs new file mode 100644 index 000000000..17c24c628 --- /dev/null +++ b/crates/ui-egui/src/code_editor.rs @@ -0,0 +1,936 @@ +//! A small code editor: monospace text with syntax colours, Tab to indent, Enter that keeps the +//! indentation, and a scroll area that follows the caret. It colours Python (for code in cells, +//! where `xl()` reads the sheet) and LaTeX (for equation source); reached as +//! `widgets::code_editor`. The tokenizer and the key edits are pure functions, tested below. + +use std::ops::Range; + +use egui::text::{CCursor, CCursorRange}; +use egui::{FontFamily, FontId, Key, Response, TextEdit, Ui}; + +use crate::theme::{self, Syntax, Tokens}; + +/// The language a code editor colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lang { + Python, + Latex, + Plain, +} + +/// A coloured span's kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Token { + Keyword, + String, + Number, + Comment, + Builtin, +} + +/// Spaces one Tab inserts. +pub const INDENT: &str = " "; + +const PY_KEYWORDS: &[&str] = &[ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "match", "case", "nonlocal", "not", "or", "pass", "raise", "return", + "try", "while", "with", "yield", +]; + +const PY_BUILTINS: &[&str] = &[ + "abs", + "all", + "any", + "bool", + "dict", + "enumerate", + "filter", + "float", + "format", + "int", + "isinstance", + "len", + "list", + "map", + "max", + "min", + "print", + "range", + "reversed", + "round", + "set", + "sorted", + "str", + "sum", + "tuple", + "type", + "zip", + "xl", +]; + +/// The coloured spans of `text` (byte ranges on char boundaries, in order, not overlapping). +/// Anything not covered is plain text. Never panics, whatever the input. +pub fn highlight(text: &str, lang: Lang) -> Vec<(Range, Token)> { + match lang { + Lang::Python => python(text), + Lang::Latex => latex(text), + Lang::Plain => Vec::new(), + } +} + +fn python(text: &str) -> Vec<(Range, Token)> { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len()); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&(start, c)) = chars.get(i) { + if c == '#' { + let mut j = i; + while chars.get(j).is_some_and(|(_, c)| *c != '\n') { + j += 1; + } + out.push((start..end_of(j), Token::Comment)); + i = j; + } else if c == '"' || c == '\'' { + let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c); + let mut j = if triple { i + 3 } else { i + 1 }; + loop { + match chars.get(j) { + None => break, + Some((_, '\\')) => j += 2, + Some((_, '\n')) if !triple => break, + Some((_, d)) if *d == c => { + if !triple { + j += 1; + break; + } + if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) { + j += 3; + break; + } + j += 1; + } + Some(_) => j += 1, + } + } + let j = j.min(chars.len()); + out.push((start..end_of(j), Token::String)); + i = j; + } else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) { + let mut j = i + 1; + while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphanumeric() || *d == '_' || *d == '.') { + j += 1; + } + out.push((start..end_of(j), Token::Number)); + i = j; + } else if c.is_alphabetic() || c == '_' { + let mut j = i + 1; + while chars.get(j).is_some_and(|(_, d)| d.is_alphanumeric() || *d == '_') { + j += 1; + } + let word = text.get(start..end_of(j)).unwrap_or(""); + if PY_KEYWORDS.contains(&word) { + out.push((start..end_of(j), Token::Keyword)); + } else if PY_BUILTINS.contains(&word) { + out.push((start..end_of(j), Token::Builtin)); + } + i = j; + } else { + i += 1; + } + } + out +} + +fn latex(text: &str) -> Vec<(Range, Token)> { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len()); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&(start, c)) = chars.get(i) { + match c { + '%' => { + let mut j = i; + while chars.get(j).is_some_and(|(_, c)| *c != '\n') { + j += 1; + } + out.push((start..end_of(j), Token::Comment)); + i = j; + } + '\\' => { + let mut j = i + 1; + if chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) { + while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) { + j += 1; + } + } else if chars.get(j).is_some() { + j += 1; // a control symbol such as \{ or \% + } + out.push((start..end_of(j), Token::Keyword)); + i = j; + } + '^' | '_' | '&' => { + out.push((start..end_of(i + 1), Token::Builtin)); + i += 1; + } + d if d.is_ascii_digit() => { + let mut j = i + 1; + while chars.get(j).is_some_and(|(_, d)| d.is_ascii_digit() || *d == '.') { + j += 1; + } + out.push((start..end_of(j), Token::Number)); + i = j; + } + _ => i += 1, + } + } + out +} + +fn color(s: &Syntax, token: Token) -> egui::Color32 { + match token { + Token::Keyword => s.keyword, + Token::String => s.string, + Token::Number => s.number, + Token::Comment => s.comment, + Token::Builtin => s.builtin, + } +} + +/// The code editor's font: the monospace face at 12.5 px. +pub fn font() -> FontId { + FontId::new(12.5, FontFamily::Name(theme::MONO.into())) +} + +/// A layout job for `text` with syntax colours. +pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, syntax: &Syntax, wrap_width: f32) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + let mut pos = 0usize; + for (range, token) in highlight(text, lang) { + if range.start < pos { + continue; + } + if let (Some(before), Some(span)) = (text.get(pos..range.start), text.get(range.clone())) { + job.append(before, 0.0, egui::TextFormat::simple(font.clone(), plain)); + job.append(span, 0.0, egui::TextFormat::simple(font.clone(), color(syntax, token))); + pos = range.end; + } + } + job.append(text.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font.clone(), plain)); + job.wrap.max_width = wrap_width; + job +} + +fn byte_of(text: &str, char_index: usize) -> usize { + text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len()) +} + +/// Start (char index) of the line holding char `at`. +fn line_start(text: &str, at: usize) -> usize { + let b = byte_of(text, at); + text.get(..b).and_then(|s| s.rfind('\n')).map(|nl| text.get(..nl + 1).map(|s| s.chars().count()).unwrap_or(0)).unwrap_or(0) +} + +/// What a key did to the text: the new caret (and selection anchor), as char indices. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Edit { + pub anchor: usize, + pub caret: usize, +} + +/// Enter: a newline that keeps the current line's indentation (one level more after a Python `:`). +/// `sel` is the selection as char indices (anchor, caret); it is replaced. +pub fn newline(text: &mut String, sel: (usize, usize), lang: Lang) -> Edit { + let count = text.chars().count(); + let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + let ls = line_start(text, a); + let line_before: String = text.chars().skip(ls).take(a - ls.min(a)).collect(); + let mut indent: String = line_before.chars().take_while(|c| *c == ' ' || *c == '\t').collect(); + if lang == Lang::Python && line_before.trim_end().ends_with(':') { + indent.push_str(INDENT); + } + let insert = format!("\n{indent}"); + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, &insert); + let caret = a + insert.chars().count(); + Edit { anchor: caret, caret } +} + +/// Typed text (or a paste) replacing the selection. +pub fn insert(text: &mut String, sel: (usize, usize), s: &str) -> Edit { + let count = text.chars().count(); + let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, s); + let caret = a + s.chars().count(); + Edit { anchor: caret, caret } +} + +/// Backspace (`back`) or Delete: the selection goes, or the character before or after the caret. +pub fn delete(text: &mut String, sel: (usize, usize), back: bool) -> Edit { + let count = text.chars().count(); + let (mut a, mut b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + if a == b { + if back { + a = a.saturating_sub(1); + } else { + b = (b + 1).min(count); + } + } + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, ""); + Edit { anchor: a, caret: a } +} + +/// An edit the editor makes itself, read off an input event by `classify`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditKey<'a> { + Insert(&'a str), + Enter, + Tab { dedent: bool }, + Backspace, + Delete, +} + +/// What one input event means to the editor. See `apply_in_order`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class<'a> { + /// The editor applies it: typed text, a paste, Enter, Tab, Shift+Tab, Backspace, Delete. + Edit(EditKey<'a>), + /// The editor takes it off the queue and does nothing: Tab under a command or option + /// modifier, which the field would turn into a tab character in place of the selection. + Dropped, + /// The text field acts on it: a caret or selection key, a command shortcut (select all, + /// undo, word deletion), IME, Copy, Cut, or a click, which the field reads from the pointer + /// state. + Field, + /// Neither reads it from the queue: a key release, a printable key press (its character + /// arrives as the `Text` event after it, also under Shift or Option), a pointer move, Escape + /// (the focus system acts on it when the pass begins). + Other, +} + +fn classify(ev: &egui::Event) -> Class<'_> { + use egui::Event; + match ev { + // The field treats a lone newline as the Enter key's business, and ignores it. + Event::Text(t) if t != "\n" && t != "\r" => Class::Edit(EditKey::Insert(t)), + Event::Text(_) => Class::Other, + Event::Paste(t) => Class::Edit(EditKey::Insert(t)), + Event::Key { key, pressed: true, modifiers, .. } => { + let command = modifiers.command || modifiers.ctrl || modifiers.mac_cmd; + match key { + // The field makes a newline of Enter under any modifier but a command one, and + // a tab character of Tab under every modifier; the editor takes all of those. + Key::Enter if !command => Class::Edit(EditKey::Enter), + Key::Tab if !command && !modifiers.alt => Class::Edit(EditKey::Tab { dedent: modifiers.shift }), + Key::Tab => Class::Dropped, + Key::Backspace if modifiers.is_none() => Class::Edit(EditKey::Backspace), + Key::Delete if modifiers.is_none() => Class::Edit(EditKey::Delete), + // The keys egui's text field reads: caret and selection moves, word and line + // deletion, and its command shortcuts (select all, undo, redo, the Emacs keys). + Key::Enter | Key::Backspace | Key::Delete => Class::Field, + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => Class::Field, + _ if command => Class::Field, + _ => Class::Other, + } + } + Event::Copy | Event::Cut | Event::Ime(_) | Event::PointerButton { pressed: true, .. } => Class::Field, + _ => Class::Other, + } +} + +/// Whether an event is part of the keyboard stream, whose order the editor keeps. Pointer, +/// touch, scroll and window events are not: egui folds them into its input state when the +/// pass begins, so they act in the pass they arrive in whatever their place among the keys. +fn is_keyboard(ev: &egui::Event) -> bool { + use egui::Event; + matches!(ev, Event::Key { .. } | Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_)) +} + +fn apply(key: EditKey<'_>, text: &mut String, sel: (usize, usize), lang: Lang) -> Edit { + match key { + EditKey::Insert(s) => insert(text, sel, s), + EditKey::Enter => newline(text, sel, lang), + EditKey::Tab { dedent } => indent(text, sel, dedent), + EditKey::Backspace => delete(text, sel, true), + EditKey::Delete => delete(text, sel, false), + } +} + +/// What `apply_in_order` did with a pass's events. +#[derive(Debug, Default, PartialEq)] +pub struct Applied { + /// Whether the text or the selection changed. + pub edited: bool, + /// The keyboard events held back for the next pass, in order: the editor's first key after + /// an event the text field acts on, and every keyboard event after that key. + pub held: Vec, +} + +/// Applies the pass's input to `text` in the order it came. A pass can carry several events +/// (typing between repaints, key repeat), and two readers take them: this editor, for typed +/// text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete, and then egui's text field, for +/// the caret and selection keys, command shortcuts, IME and clicks. The editor runs first, so +/// it can only apply its keys up to the first event the field acts on: its edits before that +/// event are applied and taken from the queue, the field's event and the run after it stay in +/// the queue for the field, and from the editor's next key on the keyboard events are held +/// back (`Applied::held`) for the next pass, where `code_editor` puts them ahead of that +/// pass's input and they go through this again. So select all, Tab in one pass indents every +/// line in two passes, and the field never gets an Enter or a Tab of a focused editor. +/// Printable key presses do not split the stream: the field reads the `Text` event that +/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret), +/// updated. +pub fn apply_in_order(events: &mut Vec, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied { + let mut out = Applied::default(); + // An event the field acts on has gone by: the editor's keys after it wait for the next pass. + let mut field_seen = false; + // A key is waiting: every keyboard event after it waits too, so the order holds. + let mut holding = false; + let mut keep = Vec::with_capacity(events.len()); + for ev in std::mem::take(events) { + if holding && is_keyboard(&ev) { + out.held.push(ev); + continue; + } + match classify(&ev) { + Class::Edit(_) if field_seen => { + holding = true; + out.held.push(ev); + } + Class::Edit(key) => { + let e = apply(key, text, *sel, lang); + *sel = (e.anchor, e.caret); + out.edited = true; + } + Class::Dropped => {} + Class::Field => { + field_seen = true; + keep.push(ev); + } + Class::Other => keep.push(ev), + } + } + *events = keep; + out +} + +/// The keyboard events a code editor held back, with the pass that held them. +#[derive(Clone, Debug, Default)] +struct Held { + pass: u64, + events: Vec, +} + +fn held_id(id: egui::Id) -> egui::Id { + id.with("held") +} + +/// The events the editor `id` held back in the previous pass, to go ahead of this pass's input +/// (see `apply_in_order`). Only the editor they were typed into gets them, only while it still +/// has the focus, and only in the pass right after: once the focus has moved on, or the editor +/// was not shown in between, they are dropped rather than typed into another widget. +fn take_held(ui: &Ui, id: egui::Id, focused: bool) -> Vec { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx() + .data_mut(|d| d.remove_temp::(held_id(id))) + .filter(|h| focused && h.pass.checked_add(1) == Some(pass)) + .map(|h| h.events) + .unwrap_or_default() +} + +/// Holds `events` back for the editor's next pass, and asks for that pass. +fn hold(ui: &Ui, id: egui::Id, events: Vec) { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx().data_mut(|d| d.insert_temp(held_id(id), Held { pass, events })); + ui.ctx().request_repaint(); +} + +/// Tab (indent) or Shift+Tab (dedent). A selection over several lines indents or dedents each +/// line; otherwise Tab inserts four spaces at the caret. +pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit { + let count = text.chars().count(); + let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + let multi = text.chars().skip(a).take(b - a).any(|c| c == '\n'); + if !dedent && !multi { + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, INDENT); + let caret = a + INDENT.len(); + return Edit { anchor: caret, caret }; + } + // Line starts (char indices) of every line the selection touches. A selection that ends at + // the start of the next line (`"a\n"` in `"a\nb"`) does not touch that line. + let mut starts = vec![line_start(text, a)]; + for (i, c) in text.chars().enumerate().skip(a).take(b - a) { + if c == '\n' && i + 1 < b { + starts.push(i + 1); + } + } + let (mut na, mut nb) = (a, b); + // Work from the last line back so earlier indices stay valid. + for &s in starts.iter().rev() { + let bs = byte_of(text, s); + let n = if dedent { + let n = text.get(bs..).map(|rest| rest.chars().take(INDENT.len()).take_while(|c| *c == ' ').count()).unwrap_or(0); + text.replace_range(bs..bs + n, ""); + n + } else { + text.insert_str(bs, INDENT); + 0 + }; + let shift = |p: usize| { + if dedent { + if p > s { p - n.min(p - s) } else { p } + } else if p >= s { + p + INDENT.len() + } else { + p + } + }; + na = shift(na); + nb = shift(nb); + } + if sel.1 >= sel.0 { Edit { anchor: na, caret: nb } } else { Edit { anchor: nb, caret: na } } +} + +/// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the +/// caret. +pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response { + let t = Tokens::get(ui.ctx()); + let font = font(); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let max_lines = max_lines.clamp(1, 200); + // The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in + // input order with the typing around it, before the text field sees the rest; what had to + // wait for the field in the previous pass comes first. + let focused = ui.memory(|m| m.has_focus(id)); + let held = take_held(ui, id, focused); + let mut edited = None; + if focused { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let count = text.chars().count(); + let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count)); + let applied = ui.input_mut(|i| { + if !held.is_empty() { + let mut events = held; + events.append(&mut i.events); + i.events = events; + } + apply_in_order(&mut i.events, text, &mut sel, lang) + }); + if applied.edited { + state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1)))); + state.store(ui.ctx(), id); + edited = Some(Edit { anchor: sel.0, caret: sel.1 }); + } + if !applied.held.is_empty() { + hold(ui, id, applied.held); + } + } + let syntax = t.syntax; + let plain = t.text; + let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { + let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let frame_h = row_h * max_lines as f32 + 8.0; + let mut response = None; + egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| { + let out = TextEdit::multiline(text) + .id(id) + .font(font.clone()) + .code_editor() + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .desired_rows(1) + .lock_focus(true) + .layouter(&mut layouter) + .show(ui); + if let Some(e) = edited { + let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2()); + ui.scroll_to_rect(caret, None); + } + // The field has no visible label; name it for screen readers and the strict + // kittest accessibility check. The editor is a generic code field, so the name is + // not part of the UI's translated strings. + let mut r = out.response.response.accessible_name("Code Editor"); + if edited.is_some() { + r.mark_changed(); + } + response = Some(r); + }); + }); + response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::Modifiers; + + fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { + highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() + } + + #[test] + fn python_tokens() { + let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); + assert_eq!( + s, + vec![ + ("def", Token::Keyword), + ("return", Token::Keyword), + ("len", Token::Builtin), + ("1.5", Token::Number), + ("# done", Token::Comment), + ("xl", Token::Builtin), + ("\"A1:B2\"", Token::String), + ] + ); + assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); + assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); + assert_eq!(spans("hello", Lang::Plain), vec![]); + } + + #[test] + fn latex_tokens() { + let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); + assert_eq!( + s, + vec![ + ("\\frac", Token::Keyword), + ("^", Token::Builtin), + ("2", Token::Number), + ("3", Token::Number), + ("% half", Token::Comment), + ("\\{", Token::Keyword), + ] + ); + } + + #[test] + fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { + let inputs = [ + "", + "'", + "\"", + "'''", + "\"\"\"x", + "#", + "\\", + "%", + "1", + ".", + "é'ü\\", + "θ = 'π' # ∑", + "\u{1F600}'\u{1F600}", + "'\\", + "\\\u{1F600}", + "\u{200B}\n\t'''\n", + "0x_é", + "a.b.c", + "____", + ]; + for text in inputs { + for lang in [Lang::Python, Lang::Latex, Lang::Plain] { + let mut last = 0; + for (r, _) in highlight(text, lang) { + assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); + assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); + last = r.end; + } + let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); + assert_eq!(job.text, text, "every character is laid out once"); + } + } + } + + #[test] + fn enter_keeps_indentation_and_indents_after_a_colon() { + let mut s = " x = 1".to_string(); + let e = newline(&mut s, (9, 9), Lang::Python); + assert_eq!(s, " x = 1\n "); + assert_eq!(e.caret, 14); + let mut s = "if x:".to_string(); + let e = newline(&mut s, (5, 5), Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(e.caret, 10); + // Plain text keeps the indentation but adds none after a colon. + let mut s = " a:".to_string(); + newline(&mut s, (4, 4), Lang::Plain); + assert_eq!(s, " a:\n "); + // A selection is replaced; out-of-range carets are clamped. + let mut s = "abcdef".to_string(); + let e = newline(&mut s, (4, 2), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); + let mut s = "é".to_string(); + let e = newline(&mut s, (99, 99), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("é\n", 2)); + } + + #[test] + fn shift_tab_in_the_editor_dedents_rather_than_indents() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + ui.ctx().set_fonts(theme::pinned_font_definitions()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + " x".to_string(), + ); + h.run_steps(2); + let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + h.input_mut().events.push(tab(Modifiers::SHIFT)); + h.run_steps(1); + assert_eq!(h.state(), " x"); + // A plain Tab still inserts spaces at the caret (the end). + h.input_mut().events.push(tab(Modifiers::NONE)); + h.run_steps(1); + assert_eq!(h.state(), " x "); + } + + /// Gives the context the deterministic font set, including the editor's monospace family. + fn install_mono(ctx: &egui::Context) { + ctx.set_fonts(theme::pinned_font_definitions()); + } + + fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + text.to_string(), + ); + h.run_steps(2); + h.check_accessibility(); + h + } + + fn key(key: Key, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + #[test] + fn batched_input_applies_in_order_and_keeps_every_key() { + // Events gather between repaints (typing, key repeat). The text typed before an Enter + // comes first, and two Enters make two lines. + let mut h = editor_harness(""); + h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n "); + // Backspace and Tab in the same frame as the typing around them. + h.input_mut().events.extend([ + egui::Event::Text("y = 1".into()), + key(Key::Backspace, Modifiers::NONE), + egui::Event::Text("2".into()), + key(Key::Tab, Modifiers::NONE), + ]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 "); + // An event the field acts on (an arrow) keeps the order too: the text typed before it + // is applied first, the field moves the caret, and the Enter after it waits for the + // next pass, where the editor applies it at the moved caret, with the indentation. + h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); + } + + #[test] + fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { + // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 2), false); + assert_eq!(s, " a\nb"); + let mut s = " a\n b".to_string(); + indent(&mut s, (0, 6), true); + assert_eq!(s, "a\n b"); + // One character into the second line, both lines are touched. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 3), false); + assert_eq!(s, " a\n b"); + } + + #[test] + fn typed_text_and_deletions_replace_the_selection() { + let mut s = "abcd".to_string(); + assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); + assert_eq!(s, "aXd"); + assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "ad"); + assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); + assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + let mut s = "éa".to_string(); + delete(&mut s, (2, 0), true); + assert_eq!(s, ""); + // The queue keeps what the editor does not handle, and everything after it. + let mut events = vec![ + egui::Event::Text("x".into()), + egui::Event::Text("\n".into()), + key(Key::ArrowLeft, Modifiers::NONE), + key(Key::Enter, Modifiers::NONE), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(out.edited); + assert_eq!((s.as_str(), sel), ("x", (1, 1))); + assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); + assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); + let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); + assert_eq!(events.len(), 1); + } + + #[test] + fn select_all_then_tab_in_one_pass_indents_every_line() { + // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the + // selection and indents the selected lines, rather than reaching the field, which would + // replace the selection with a tab character. + let mut h = editor_harness("a\nb"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); + h.run_steps(1); + assert_eq!(h.state(), " a\n b"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); + h.run_steps(2); + assert_eq!(h.state(), "a\nb"); + } + + #[test] + fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { + // egui-winit sends a Key event before each Text event. Those presses are nobody's to + // read, so they do not hand the Enter to the field, which knows no indentation. + let mut h = editor_harness(""); + let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; + let mut events = Vec::new(); + events.extend(typed(Key::I, "i", Modifiers::NONE)); + events.extend(typed(Key::F, "f", Modifiers::NONE)); + events.extend(typed(Key::Space, " ", Modifiers::NONE)); + events.extend(typed(Key::X, "x", Modifiers::NONE)); + events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); + events.push(key(Key::Enter, Modifiers::NONE)); + h.input_mut().events.extend(events); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n "); + // A character typed with Option (macOS) comes as a key under that modifier and its text; + // the field reads neither the key nor Option, so the Enter after it is still the editor's. + h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \u{e9}\n "); + } + + #[test] + fn tab_and_enter_under_modifiers_never_reach_the_field() { + // The field would make a tab character of Ctrl+Tab (in place of the selection) and a + // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. + let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; + let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); + assert!(out.held.is_empty()); + } + + #[test] + fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { + let mut events = vec![ + key(Key::X, Modifiers::NONE), + egui::Event::Text("x".into()), + key(Key::A, Modifiers::COMMAND), + key(Key::Tab, Modifiers::NONE), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::ArrowLeft, Modifiers::NONE), + egui::Event::Text("y".into()), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); + assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); + } + + #[test] + fn held_back_events_land_in_the_next_pass_only() { + let mut h = editor_harness(""); + let id = egui::Id::unique("code"); + let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; + // What the pass just completed held back comes in with the next one. + let last = h.ctx.cumulative_pass_nr() - 1; + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + // Anything older is dropped rather than typed out of context, and cleared. + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); + } + + #[test] + fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { + // Typed into the editor, they must not land in whatever has the focus next. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other")); + ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); + code_editor(ui, code, &mut s.0, Lang::Python, 5); + ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field"); + }, + (String::new(), String::new(), true), + ); + h.run_steps(2); + h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); + h.run_steps(1); + assert_eq!(h.state().0, "", "the x waits for the arrow"); + h.state_mut().2 = false; + h.run_steps(2); + assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); + } + + #[test] + fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { + // When a widget asks egui to discard and repeat the pass, the repeat pass gets the + // held-back keys, with the selection the field made in the first pass. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, &mut s.0, Lang::Python, 5); + if std::mem::take(&mut s.1) { + ui.ctx().request_discard("the test repeats this pass"); + } + }, + ("a\nb".to_string(), false), + ); + h.run_steps(2); + h.state_mut().1 = true; + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state().0, " a\n b"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6eb8aece9..55d0e23d7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -586,6 +586,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - for (id, label, params) in [ ("ui.ribbonTab", "Ribbon Tab", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), + ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4da46bc22..7a685c2af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -642,6 +642,20 @@ impl Dialog { ], json!({"groupBy": "A", "function": "sum"}), ), + "outlineSettings" => { + let (below, right) = + app.session.active().and_then(|d| d.wb.active().map(|s| (s.summary_below, s.summary_right))).unwrap_or((true, true)); + Dialog::form( + "outlineSettings", + "Settings", + "data.outlineSettings", + vec![ + Check { key: "summaryBelow", label: "Summary rows below detail" }, + Check { key: "summaryRight", label: "Summary columns to right of detail" }, + ], + json!({"summaryBelow": below, "summaryRight": right}), + ) + } "zoom" => { // The current zoom is pre-selected (Custom when it matches no preset). Fit // selection needs the grid's size, so it is captured here for `view.zoomToSelection`. @@ -786,7 +800,12 @@ impl Dialog { d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() }; d } - "goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})), + "goTo" => { + let mut dialog = Dialog::custom("goTo", "Go To", json!({"reference": ""})); + // Listed once, when the dialog opens. + dialog.result = Some(json!(go_to_entries(app))); + dialog + } "pivotCalculatedField" => Dialog::form( "pivotCalculatedField", "Insert Calculated Field", @@ -824,6 +843,19 @@ impl Dialog { Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]})) } "nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})), + "pasteName" => { + // F3 opens it only when the workbook has names to paste, as in Excel. + let names = pasteable_names(app); + if p("shortcut") == json!(true) && names.is_empty() { + return None; + } + // OK runs `formulas.pasteName` with the selected `name`, so `ui.dialog.confirm` does too. + let mut dialog = Dialog::custom("pasteName", "Paste Name", json!({"name": names.first()})); + dialog.command = Some("formulas.pasteName"); + // Listed once, when the dialog opens. + dialog.result = Some(json!(names)); + dialog + } "manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})), "commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})), "agents" => Dialog::custom("agents", "Agent Control", json!({})), @@ -1264,6 +1296,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), "sort" => sort(app, ui, &mut d, &mut confirm), "nameManager" => name_manager(app, ui, &mut d, &mut open), + "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), "manageRules" => manage_rules(app, ui), "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), @@ -1592,7 +1625,7 @@ fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: *confirm = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent)); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } @@ -1675,10 +1708,8 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let lang = crate::i18n::current(ui.ctx()); - let ok = ui.add( - egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) - .fill(crate::theme::Tokens::get(ui.ctx()).accent), - ); + let t = crate::theme::Tokens::get(ui.ctx()); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } @@ -2325,14 +2356,37 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) ok_cancel(ui, confirm, open); } +/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to +/// before, newest first. +fn go_to_entries(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| { + let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet); + v.extend(doc.go_to_recent.iter().cloned()); + v + }) + .unwrap_or_default() +} + fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let lang = app.ui.language; - let names: Vec = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); - for n in names { - if ui.selectable_label(false, &n).clicked() { - d.values.insert("reference".into(), json!(n)); + let entries: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { + for n in entries.get(rows).unwrap_or_default() { + let resp = ui.selectable_label(current == *n, n); + if resp.clicked() || resp.double_clicked() { + d.values.insert("reference".into(), json!(n)); + } + if resp.double_clicked() { + *confirm = true; + } } - } + }); let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); ui.horizontal(|ui| { let label = ui.label(lang.tr("Reference:")); @@ -2346,7 +2400,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo let mut open = true; ok_cancel(ui, confirm, &mut open); if *confirm { - app.run_or_alert("edit.goTo", json!({"reference": r})); + app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); app.grid.ensure_visible = true; } if !open { @@ -2511,8 +2565,76 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu }); } +/// The names Paste Names offers in the active workbook. +fn pasteable_names(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default() +} + +/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it +/// starts one in the active cell with `=` and the name. +pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { + match app.editor.as_mut() { + Some(ed) => ed.insert_at_caret(name), + None => app.begin_edit(Some(format!("={name}")), false), + } +} + +/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the +/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it +/// refers to from the active cell. +fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + // The `name` value is the selection, so `ui.dialog.set` can choose one too. + if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { + d.list_index = i; + } + let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); + let last = names.len().saturating_sub(1); + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + d.list_index = d.list_index.min(last); + ui.label(app.ui.language.tr("Paste name")); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + if names.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); + } + for (i, n) in names.iter().enumerate() { + let r = ui.selectable_label(i == d.list_index, n); + if (down || up) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + *confirm = true; + } + } + }); + ui.add_space(4.0); + // Paste List is disabled while a cell is being edited. + if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { + app.run_or_alert("formulas.pasteName", json!({"list": true})); + *open = false; + return; + } + if let Some(n) = names.get(d.list_index) { + d.values.insert("name".into(), json!(n)); + } + ok_cancel(ui, confirm, open); +} + /// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. -const ALL_CHARTS: [(&str, &str, &str); 20] = [ +const ALL_CHARTS: [(&str, &str, &str); 21] = [ ("column", "", "Clustered Column"), ("column", "stacked", "Stacked Column"), ("column", "stacked100", "100% Stacked Column"), @@ -2526,6 +2648,7 @@ const ALL_CHARTS: [(&str, &str, &str); 20] = [ ("doughnut", "", "Doughnut"), ("area", "", "Area"), ("area", "stacked", "Stacked Area"), + ("area", "stacked100", "100% Stacked Area"), ("scatter", "", "Scatter"), ("scatter", "lines", "Scatter with Lines"), ("radar", "", "Radar"), @@ -3091,8 +3214,8 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool ); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let solve = - ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); if solve.clicked() && !text(d, "objective").trim().is_empty() { *confirm = true; } @@ -3167,7 +3290,7 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo let word = it["word"].as_str().unwrap_or("").to_string(); let cell = it["cell"].as_str().unwrap_or("").to_string(); ui.label(format!("Not in Dictionary ({cell}):")); - ui.label(egui::RichText::new(&word).strong().color(Color32::from_rgb(0xC4, 0x2B, 0x1C))); + ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); let sugg: Vec = it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); ui.label("Suggestions:"); diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8984ad53c..6eca077f9 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -1,20 +1,10 @@ //! In-cell / formula-bar editing state: point mode, reference colouring, function //! autocomplete and argument hints. -use egui::Color32; use gridcraft_engine::core::{CellRef, RangeRef}; -/// Colours for references in a formula being edited (our own palette). -pub const REF_COLORS: [Color32; 8] = [ - Color32::from_rgb(0x2E, 0x6F, 0xD8), - Color32::from_rgb(0xD0, 0x3C, 0x3C), - Color32::from_rgb(0x7A, 0x4F, 0xC9), - Color32::from_rgb(0x2B, 0x9A, 0x4A), - Color32::from_rgb(0xC2, 0x5E, 0x1E), - Color32::from_rgb(0x1D, 0x92, 0xA6), - Color32::from_rgb(0xB8, 0x3B, 0x8E), - Color32::from_rgb(0x8A, 0x7A, 0x1F), -]; +/// How many distinct reference colours a formula cycles through (`Tokens::ref_colors`). +pub const REF_COLOR_COUNT: usize = 8; #[derive(Clone, Debug)] pub struct EditState { @@ -28,6 +18,9 @@ pub struct EditState { pub from_formula_bar: bool, /// Caret position (char index), mirrored from the text widget. pub caret: usize, + /// The other end of the selected text (char index), mirrored like `caret`; equal to `caret` + /// when nothing is selected. + pub anchor: usize, /// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it. pub point: Option<(usize, usize)>, /// Where point mode's keyboard cursor is. @@ -39,6 +32,9 @@ pub struct EditState { pub request_focus: bool, /// The text changed programmatically: push `caret` into the widget on the next frame. pub sync_caret: bool, + /// A dialog opened over the edit has the keyboard: when the editor takes it back without a + /// change to the caret, the selected text (`anchor`..`caret`) stays selected. + pub keep_selection: bool, /// Column AutoComplete: the full entry the typed text completes to. pub completion: Option, /// Move the caret to the end on the next frame. @@ -56,6 +52,7 @@ impl EditState { enter_mode, from_formula_bar, caret, + anchor: caret, point: None, point_cell: None, point_anchor: None, @@ -63,6 +60,7 @@ impl EditState { ac_index: 0, request_focus: true, sync_caret: true, + keep_selection: false, caret_to_end: true, completion: None, } @@ -112,6 +110,27 @@ impl EditState { self.sync_caret = true; } + /// Inserts `t` at the caret as typed text (Paste Names), in place of the selected text: the + /// caret ends after it, and point mode starts afresh. + pub fn insert_at_caret(&mut self, t: &str) { + // When code moved the caret this frame (`sync_caret`), the widget has not yet stored it, so `anchor` is out of + // date and the name goes in at the caret. + let (from, to) = if self.sync_caret { (self.caret, self.caret) } else { (self.anchor.min(self.caret), self.anchor.max(self.caret)) }; + let byte = |c: usize| self.text.char_indices().nth(c).map(|(i, _)| i).unwrap_or(self.text.len()); + let (Some(a), Some(b)) = (self.text.get(..byte(from)), self.text.get(byte(to)..)) else { return }; + self.text = format!("{a}{t}{b}"); + self.point = None; + self.caret = from + t.chars().count(); + self.anchor = self.caret; + // A function suggestion shown before the name went in no longer fits the text. + self.autocomplete.clear(); + self.ac_index = 0; + self.completion = None; + self.caret_to_end = false; + self.sync_caret = true; + self.request_focus = true; + } + /// Add another argument after the current pointed reference. Keep only the new reference /// replaceable, so a subsequent plain click does not remove earlier arguments or the separator. pub fn append_ref(&mut self, r: &str) { @@ -293,7 +312,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, seen.len() - 1 } }; - out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len())); + out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT)); continue; } i += 1; @@ -379,6 +398,21 @@ mod tests { assert!(!e2.can_point()); } + #[test] + fn a_pasted_name_replaces_the_selected_text() { + let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false); + e.sync_caret = false; + (e.anchor, e.caret) = (8, 1); + e.autocomplete = vec!["SUM".into()]; + e.insert_at_caret("Sales"); + assert_eq!((e.text.as_str(), e.caret, e.anchor), ("=Sales+1", 6, 6)); + assert!(e.autocomplete.is_empty(), "the earlier function suggestion is gone"); + // Nothing selected: the name goes in at the caret. + e.sync_caret = false; + e.insert_at_caret("Rate"); + assert_eq!((e.text.as_str(), e.caret), ("=SalesRate+1", 10)); + } + #[test] fn autocomplete_and_hints() { let names: Vec = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 215c6070c..37a6d5ac3 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -1,65 +1,149 @@ //! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget. -use egui::{Color32, FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; +use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; use serde_json::json; use crate::SheetApp; -use crate::editor::{REF_COLORS, formula_refs}; +use crate::editor::formula_refs; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +/// Lines the formula bar (and the in-cell editor) grows to before it scrolls. +pub const MAX_LINES: usize = 5; +/// The bar's height at one line. +const ONE_LINE: f32 = 30.0; +/// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) +/// and two pixels for the rounding to the pixel grid. +const PAD: f32 = 14.0; + +/// The bar's height for one line of a font whose rows are `row_h` tall: Excel's 30 px, unless +/// the rows need more (the Windows UI font's row is 17.28 px). Every line counted must fit in +/// the field, whatever the font; `n` lines take `one_line(row_h) + (n - 1) * row_h`. +pub fn one_line(row_h: f32) -> f32 { + ONE_LINE.max(row_h + PAD) +} +/// How long the bar takes to grow or shrink to a new line count. +const EASE_SECONDS: f32 = 0.12; + +/// Where the formula bar put its parts last frame (for tests). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BarLayout { + pub name_box: Rect, + /// ✕ ✓ fx. + pub buttons: Rect, + pub field: Rect, + pub height: f32, + /// Laid-out lines of the content (wrapped to the field), and how many show without scrolling. + pub lines: usize, + pub visible_lines: usize, + /// The caret while editing in the bar. + pub caret: Option, +} + +impl Default for BarLayout { + fn default() -> Self { + BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None } + } +} + +impl BarLayout { + pub fn id() -> egui::Id { + egui::Id::unique("formula_bar.layout") + } + + /// Last frame's layout. + pub fn get(ctx: &egui::Context) -> BarLayout { + ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default() + } +} + +/// What the editor widget laid out: its line count and caret. +#[derive(Clone, Copy, Debug, Default)] +pub struct EditorInfo { + pub lines: usize, + pub caret: Option, +} + pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let expanded = app.ui.formula_bar_expanded; - let h = if expanded { 72.0 } else { 30.0 }; + let font = theme::ui_font(13.0); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let last = BarLayout::get(ui.ctx()); + // Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it + // at MAX_LINES. It eases only when that target changes. + let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; + let target = one_line(row_h) + (lines - 1) as f32 * row_h; + let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); + let mut layout = BarLayout { height: h, ..Default::default() }; egui::Panel::top("formula_bar") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { - ui.horizontal(|ui| { + // Top-aligned: the Name Box and buttons stay on the first line as the field grows. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { ui.spacing_mut().item_spacing.x = 4.0; - name_box(app, ui, &t); + layout.name_box = name_box(app, ui, &t); ui.add_space(4.0); let editing = app.editor.is_some(); let c = if editing { t.text } else { t.text_disabled }; - if crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 22.0)).clicked() && editing { + let cancel = crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 24.0)); + if cancel.clicked() && editing { app.cancel_edit(); } - if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 22.0)).clicked() && editing { + if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 24.0)).clicked() && editing { app.commit_edit(0, 0, false, false); } - if crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 22.0)).clicked() { + let fx = crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 24.0)); + if fx.clicked() { app.open_dialog("insertFunction", json!({})); } + layout.buttons = cancel.rect.union(fx.rect); ui.add_space(4.0); - // The formula field. + // The formula field fills the rest, as tall as the bar. let avail = ui.available_width() - 24.0; - let field = Rect::from_min_size(ui.cursor().min, vec2(avail, h - 6.0)); + let field = Rect::from_min_size(ui.cursor().min, vec2(avail, ui.available_height())); ui.painter().rect_filled(field, 4.0, t.input_bg); ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); + let inner = field.shrink2(vec2(6.0, 3.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); let id = egui::Id::unique("gridcraft.formula_bar"); + // A spill child shows its anchor's formula muted and read-only. let read_only = active_spill_anchor(app).is_some(); - if app.editor.is_some() { - editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); + let info = if app.editor.is_some() { + editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height()) } else { // Show the active cell's content; clicking starts editing in the bar. let text = active_input(app); let color = if read_only { t.text_disabled } else { t.text }; - let resp = child - .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click())); - let click = child.interact(field, id.with("click"), Sense::click()); - if !read_only && (resp.clicked() || click.clicked()) { + let galley = child.painter().layout(text, font.clone(), color, inner.width()); + let lines = galley.rows.len(); + // Each cell's view starts at its top: the offset is kept per cell, not per bar. + let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col)); + egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show( + &mut child, + |ui| { + if ui.add(egui::Label::new(galley).sense(Sense::click())).clicked() && !read_only { + app.begin_edit(None, true); + } + }, + ); + if !read_only && ui.interact(field, id.with("click"), Sense::click()).clicked() { app.begin_edit(None, true); } - } - ui.allocate_space(vec2(avail, h - 6.0)); - let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron }; - if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() { + EditorInfo { lines, caret: None } + }; + layout.field = field; + layout.lines = info.lines.max(1); + layout.visible_lines = ((inner.height() / row_h).floor() as usize).max(1); + layout.caret = info.caret; + ui.allocate_space(vec2(avail, field.height())); + let chevron = if app.ui.formula_bar_expanded { Icon::ChevronUp } else { Icon::Chevron }; + if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 24.0)).clicked() { app.ui.formula_bar_expanded = !app.ui.formula_bar_expanded; } }); }); + ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } /// Resolve ownership on every frame; never cache a spill range in presentation state. @@ -101,7 +185,7 @@ fn active_input(app: &SheetApp) -> String { String::new() } -fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { +fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect { let id = egui::Id::unique("gridcraft.name_box"); let shown = app.name_box_text(); let mut text = app.name_box.clone().unwrap_or(shown.clone()); @@ -163,6 +247,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { } } }); + rect } /// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character. @@ -177,9 +262,33 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool { text.chars().count() < chars_before } -/// The text editor used in the cell and in the formula bar (they share `app.editor`). -pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { - let Some(mut ed) = app.editor.take() else { return }; +/// The editor's text field, the same in the cell and in the formula bar. Enter is the editor's +/// own key (it commits, and Alt+Enter breaks the line), so the field itself does nothing with it: +/// a message box over the editor takes Enter to close, and the field must not add a line then. +fn show_edit<'t>( + edit: TextEdit<'t>, + ui: &mut egui::Ui, + id: egui::Id, + font: FontId, + width: f32, + layouter: &'t mut dyn FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc, +) -> egui::text_edit::TextEditOutput { + edit.id(id) + .frame(egui::Frame::NONE) + .font(font) + .desired_width(width) + .desired_rows(1) + .layouter(layouter) + .lock_focus(true) + .return_key(None) + .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + .show(ui) +} + +/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows +/// `max_height` of text and scrolls beyond that, keeping the caret in view. +pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo { + let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); @@ -189,7 +298,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let mut cancel = false; // 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() { + // So does a dialog opened from the editor (Paste Name): its OK takes Enter. + if has_focus && mine && app.message.is_none() && app.dialog.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 = @@ -279,6 +389,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) { cycle_anchor(&mut ed); } + // F3 opens Paste Name when the workbook has names; OK puts the name at the caret. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) { + app.open_dialog("pasteName", json!({"shortcut": true})); + } // Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere, // as in Excel, so it never falls through to the text's select-all. if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) { @@ -295,11 +409,16 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: state.store(ui.ctx(), id); } } + // Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar while editing too. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) { + app.run_or_alert("view.expandFormulaBar", json!({})); + } } // The text widget. let refs = formula_refs(&ed.text); - let text_color = Tokens::get(ui.ctx()).text; - let cell_text_color = if id == egui::Id::unique("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; + let t = Tokens::get(ui.ctx()); + let on_sheet = id == egui::Id::unique("gridcraft.cell_editor"); + let cell_text_color = if on_sheet { t.cell_text } else { t.text }; let font2 = font.clone(); let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { let s = buf.as_str(); @@ -312,7 +431,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: continue; } job.append(s.get(pos..a).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color)); - job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), REF_COLORS[idx])); + job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), t.ref_color(idx, on_sheet))); pos = b; } job.append(s.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color)); @@ -320,35 +439,57 @@ 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(); - // The in-cell editor is as tall as its lines (a multiline field reserves four rows). - let in_cell = id == egui::Id::unique("gridcraft.cell_editor"); - let edit = if multiline || ed.text.contains('\n') { - TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 }) + // A dialog opened over the edit (Paste Name) takes the keyboard until it closes, and the + // editor takes it back afterwards. + let dialog_open = app.dialog.is_some(); + if dialog_open && mine { + ed.request_focus = true; + ed.keep_selection = true; + } + let synced = mine && !dialog_open && (ed.request_focus || ed.sync_caret); + let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; + let edit = edit.interactive(!dialog_open); + let caret_index = ed.caret; + // The in-cell editor grows with its text and never scrolls, so the field keeps its whole width + // for the text (a scroll area would take the bar's width off it and wrap the text narrower + // than the cell does). The formula bar shows its lines and scrolls past them. + let mut output = if on_sheet { + show_edit(edit, ui, id, font, width, &mut layouter) } else { - TextEdit::singleline(&mut ed.text) + egui::ScrollArea::vertical() + .id_salt(id.with("scroll")) + .max_height(max_height.max(1.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + let output = show_edit(edit, ui, id, font, width, &mut layouter); + // A caret moved by our own keys (Alt+Enter, F4, point mode) isn't the TextEdit's + // doing, so it doesn't scroll to it itself. + if synced { + let caret = output.galley.pos_from_cursor(egui::text::CCursor::new(caret_index)).translate(output.galley_pos.to_vec2()); + ui.scroll_to_rect(caret.expand(2.0), None); + } + output + }) + .inner }; - let mut output = edit - .id(id) - .frame(egui::Frame::NONE) - .font(font) - .desired_width(width) - .layouter(&mut layouter) - .lock_focus(true) - .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - .show(ui); // Both editors are text inputs without a visible label next to them: name the one being shown. - let accessible_name = (if in_cell { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string(); - (*output.response).clone().accessible_name(accessible_name); - let resp = output.response.clone(); - let synced = mine && (ed.request_focus || ed.sync_caret); + let accessible_name = (if on_sheet { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string(); + let resp = output.response.response.clone().accessible_name(accessible_name); if synced { if ed.request_focus { resp.request_focus(); } ed.request_focus = false; + // Closing a dialog that changed nothing (Cancel) gives the selection back as it was. + let range = if ed.keep_selection && !ed.sync_caret { + egui::text::CCursorRange::two(egui::text::CCursor::new(ed.anchor), egui::text::CCursor::new(ed.caret)) + } else { + ed.anchor = ed.caret; + egui::text::CCursorRange::one(egui::text::CCursor::new(ed.caret)) + }; ed.sync_caret = false; - let cursor = egui::text::CCursor::new(ed.caret); - output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(cursor))); + ed.keep_selection = false; + output.state.cursor.set_char_range(Some(range)); output.state.store(ui.ctx(), id); } else if resp.clicked() || resp.gained_focus() { // Clicking into the other editor moves editing there. @@ -356,8 +497,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.enter_mode = false; app.session.mode = gridcraft_engine::Mode::Edit; } - if !synced && let Some(r) = output.cursor_range { + if !synced + && !dialog_open + && let Some(r) = output.cursor_range + { ed.caret = r.primary.index.into(); + ed.anchor = r.secondary.index.into(); } if resp.changed() { ed.completion = None; @@ -396,11 +541,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let g = ui.painter().layout_no_wrap( rest.to_string(), output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(), - Color32::WHITE, + t.on_accent, ); let r = egui::Rect::from_min_size(pos, g.size()); - ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8)); - ui.painter().galley(pos, g, Color32::WHITE); + ui.painter().rect_filled(r, 0.0, t.accent); + ui.painter().galley(pos, g, t.on_accent); } // Autocomplete list and argument hint under the editor. Decide visibility from the area the // editor was allotted, not from its response: egui 0.37 gives every text field a @@ -467,6 +612,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }); }); } + let lines = output.galley.rows.len().max(1); + let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); app.editor = Some(ed); if cancel { app.cancel_edit(); @@ -477,6 +624,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: app.commit_edit(dr, dc, array, fill); } } + EditorInfo { lines, caret } } fn cycle_anchor(ed: &mut crate::editor::EditState) { @@ -701,4 +849,21 @@ mod tests { assert!(entry_shrank(4, "Som")); assert!(!entry_shrank(4, "Same")); } + + #[test] + fn one_line_fits_a_row_of_any_font() { + // macOS and Linux UI fonts: rows of 15 to 16 px keep Excel's 30 px bar. + assert_eq!(one_line(15.6), 30.0); + assert_eq!(one_line(16.0), 30.0); + // The Windows UI font's 17.28 px rows need more: the panel's and the field's margins take + // 12 px, and rounding to the pixel grid up to another one, so 30 px held only four rows + // of five (a tester's finding). Every counted line must fit in what is left. + for row_h in [17.0_f32, 17.28, 18.0, 20.0] { + for lines in 1..=MAX_LINES { + let bar = one_line(row_h) + (lines - 1) as f32 * row_h; + let inner = bar - 13.0; + assert!((inner / row_h).floor() as usize >= lines, "{lines} lines of {row_h} px in a {bar} px bar"); + } + } + } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index fa092bafd..dbc60eb22 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -12,7 +12,7 @@ use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, use serde_json::json; use crate::SheetApp; -use crate::editor::{EditState, REF_COLORS}; +use crate::editor::EditState; use crate::theme::{self, Tokens}; pub const COL_HEADER_H: f32 = 21.0; @@ -163,6 +163,11 @@ pub struct GridState { /// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot): /// it scans the whole source list, far too slow to repeat every frame (#2113). pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>, + /// The cells of a PivotTable report where the cell menu offers Show Details, relative to the + /// report's top-left and cached per (doc uid, revision, sheet, pivot): finding them computes + /// the whole report. `None` when the report can't be computed or is out of date: every cell + /// of it offers Show Details, and running it says why. + pub show_details_cells: Option<((u64, u64, usize, usize), Option>)>, pub header_menu: Option<(Pos2, bool)>, /// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns). pub paper: Option, @@ -176,6 +181,11 @@ pub struct GridState { pub geo: Option, /// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests). pub hscroll: Option, + /// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests). + pub tab_split: Option, + /// The share of the tab bar each workbook's tabs (and New sheet) take after the handle was + /// dragged, by document uid. A workbook without one gives its tabs up to 60%. + pub tab_shares: std::collections::HashMap, /// Ctrl+wheel zoom in progress (see [`WheelZoom`]). pub wheel_zoom: Option, /// A one-finger touch drag in progress: it pans the sheet instead of selecting. @@ -252,8 +262,8 @@ impl Geo { let (fr, fc) = sh.freeze.unwrap_or((0, 0)); let frozen_w = (ci.start(fc) as f32) * z; let frozen_h = (ri.start(fr) as f32) * z; - let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows)); - let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols)); + let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); + let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). let symbols = sh.show_outline_symbols; let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; @@ -1227,7 +1237,7 @@ pub(crate) fn paint_quadrant( StrokeKind::Inside, ); if on { - crate::icons::paint(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); + crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); } continue; } @@ -1558,9 +1568,15 @@ pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec bool { + let Some(d) = app.session.active() else { return false }; + let (si, at) = (d.wb.active_sheet, d.selection.active); + let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at)))) + else { + return false; + }; + if !pt.enable_show_details { + return false; + } + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + let key = (d.uid, d.revision, si, pi); + if let Some((k, cells)) = &app.grid.show_details_cells + && *k == key + { + return cells.as_ref().is_none_or(|c| c.contains(&cell)); + } + let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok(); + let on = cells.as_ref().is_none_or(|c| c.contains(&cell)); + app.grid.show_details_cells = Some((key, cells)); + on +} + /// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. const FILL_MODES: [(&str, &str); 4] = [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; +/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts +/// with one. +const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")]; + +/// The fill modes the menus list for a fill from `src` on the active sheet. +fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> { + let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src)); + FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() +} + /// Remembers the fill just made so the Auto Fill Options button can offer the other ways. fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { app.grid.fill_tag = @@ -3438,7 +3502,8 @@ fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { /// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill /// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only -/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or +/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode. /// The button goes away as soon as anything else changes the workbook or the selection moves. fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { let Some(tag) = app.grid.fill_tag.clone() else { return }; @@ -3481,9 +3546,10 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet Stroke::NONE, )); let mut chosen = None; + let modes = fill_modes(app, tag.src); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(190.0); - for (label, mode) in FILL_MODES { + for (label, mode) in modes { if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { chosen = Some(mode); ui.close(); @@ -3510,10 +3576,11 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((src, target, at)) = app.grid.fill_menu else { return }; let mut close = false; + let mut items = fill_modes(app, src); + items.extend([("-", ""), ("Series…", "ui:series")]); let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; let lang = crate::i18n::current(ui.ctx()); for (label, mode) in items { if label == "-" { @@ -3679,9 +3746,10 @@ pub(crate) fn paint_overlays( if app.editor.is_none() && let Some(r) = validation_arrow(sh, geo, sel.active) { - p.rect_filled(r, 2.0, Color32::from_gray(245)); - p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); - crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60)); + let t = Tokens::get(p.ctx()); + p.rect_filled(r, 2.0, t.sheet_control_bg); + p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); + crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text); } // Input message of the active cell's validation, hidden while its list is open: it would cover the items. if app.editor.is_none() diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 912d6c26b..174a1f6ee 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Odebrat šipky", "Show Formulas" => "Zobrazit vzorce", "Error Checking" => "Kontrola chyb", + "Circular References" => "Cyklické odkazy", "Evaluate Formula" => "Vyhodnotit vzorec", "Watch Window" => "Okno sledování", "Calculation\nOptions" => "Možnosti\nvýpočtu", @@ -209,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Zápatí", "Different First Page" => "Jiná první stránka", "Different Odd & Even Pages" => "Jiné liché a sudé stránky", + "Scale with Document" => "Měnit velikost s dokumentem", + "Align with Page Margins" => "Zarovnat k okrajům stránky", "Go to Footer" => "Přejít na zápatí", "Go to Header" => "Přejít na záhlaví", "Go to\nFooter" => "Přejít\nna zápatí", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 565e3609d..557e13ffd 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pfeile entfernen", "Show Formulas" => "Formeln anzeigen", "Error Checking" => "Fehlerüberprüfung", + "Circular References" => "Zirkelbezüge", "Evaluate Formula" => "Formel auswerten", "Watch Window" => "Überwachungsfenster", "Calculation\nOptions" => "Berechnungs-\noptionen", @@ -213,6 +214,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Fußzeile", "Different First Page" => "Erste Seite anders", "Different Odd & Even Pages" => "Gerade und ungerade Seiten anders", + "Scale with Document" => "Mit Dokument skalieren", + "Align with Page Margins" => "An Seitenrändern ausrichten", "Go to Footer" => "Zur Fußzeile wechseln", "Go to Header" => "Zur Kopfzeile wechseln", "Go to\nFooter" => "Zur\nFußzeile", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index b97e70ed7..4cced5da9 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -141,6 +141,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Κατάργηση βελών", "Show Formulas" => "Εμφάνιση τύπων", "Error Checking" => "Έλεγχος σφαλμάτων", + "Circular References" => "Κυκλικές αναφορές", "Evaluate Formula" => "Αξιολόγηση τύπου", "Watch Window" => "Παράθυρο παρακολούθησης", "Calculation\nOptions" => "Επιλογές\nυπολογισμού", @@ -205,6 +206,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Υποσέλιδο", "Different First Page" => "Διαφορετική πρώτη σελίδα", "Different Odd & Even Pages" => "Διαφορετικές μονές και ζυγές σελίδες", + "Scale with Document" => "Κλιμάκωση με το έγγραφο", + "Align with Page Margins" => "Στοίχιση με τα περιθώρια σελίδας", "Go to Footer" => "Μετάβαση στο υποσέλιδο", "Go to Header" => "Μετάβαση στην κεφαλίδα", "Go to\nFooter" => "Μετάβαση στο\nυποσέλιδο", @@ -881,6 +884,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Δακτύλιος", "Area" => "Περιοχή", "Stacked Area" => "Σωρευμένη περιοχή", + "100% Stacked Area" => "Σωρευμένη περιοχή 100%", "Scatter" => "Διασπορά", "Scatter with Lines" => "Διασπορά με γραμμές", "Radar" => "Ακτινωτό", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 350b3b6eb..3437432fe 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Quitar flechas", "Show Formulas" => "Mostrar fórmulas", "Error Checking" => "Comprobación de errores", + "Circular References" => "Referencias circulares", "Evaluate Formula" => "Evaluar fórmula", "Watch Window" => "Ventana Inspección", "Calculation\nOptions" => "Opciones\nde cálculo", @@ -212,6 +213,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Pie de página", "Different First Page" => "Primera página diferente", "Different Odd & Even Pages" => "Páginas pares e impares diferentes", + "Scale with Document" => "Escalar con el documento", + "Align with Page Margins" => "Alinear con los márgenes de página", "Go to Footer" => "Ir al pie de página", "Go to Header" => "Ir al encabezado", "Go to\nFooter" => "Ir al pie\nde página", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index f8c7ac80c..7a7ec2b9a 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -4,6 +4,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Different First Page" => "Première page différente", "Different Odd & Even Pages" => "Pages paires et impaires différentes", + "Scale with Document" => "Mettre à l'échelle avec le document", + "Align with Page Margins" => "Aligner sur les marges de la page", "Paste" => "Coller", "Paste Values" => "Coller les valeurs", "Paste Formulas" => "Coller les formules", @@ -154,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Supprimer les flèches", "Show Formulas" => "Afficher les formules", "Error Checking" => "Vérification des erreurs", + "Circular References" => "Références circulaires", "Evaluate Formula" => "Évaluation de formule", "Watch Window" => "Fenêtre Espion", "Calculation\nOptions" => "Options de\ncalcul", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f476be532..0d168ae49 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Rimuovi frecce", "Show Formulas" => "Mostra formule", "Error Checking" => "Controllo errori", + "Circular References" => "Riferimenti circolari", "Evaluate Formula" => "Valuta formula", "Watch Window" => "Finestra controlli", "Calculation\nOptions" => "Opzioni di\ncalcolo", @@ -331,6 +332,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Vai a intestazione", "Different First Page" => "Prima pagina diversa", "Different Odd & Even Pages" => "Pagine pari e dispari diverse", + "Scale with Document" => "Ridimensiona con il documento", + "Align with Page Margins" => "Allinea ai margini della pagina", "Go to Footer" => "Vai a piè di pagina", "Ruler" => "Righello", // File › Backstage view. @@ -765,6 +768,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Anello", "Area" => "Area", "Stacked Area" => "Area in pila", + "100% Stacked Area" => "Area in pila 100%", "Scatter" => "Dispersione", "Scatter with Lines" => "Dispersione con linee", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e66610564..d1d2303db 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Remove Arrows", "矢印の解除"), ("Show Formulas", "数式の表示"), ("Error Checking", "エラーチェック"), + ("Circular References", "循環参照"), ("Evaluate Formula", "数式の検証"), ("Watch Window", "ウォッチ ウィンドウ"), ("Calculation\nOptions", "計算方法の設定"), @@ -335,6 +336,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Go to Header", "ヘッダーに移動"), ("Different First Page", "先頭ページのみ別指定"), ("Different Odd & Even Pages", "奇数/偶数ページ別指定"), + ("Scale with Document", "文書に合わせて拡大/縮小"), + ("Align with Page Margins", "ページ余白に合わせる"), ("Go to Footer", "フッターに移動"), ("Ruler", "ルーラー"), ("File", "ファイル"), @@ -774,6 +777,7 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Doughnut", "ドーナツ"), ("Area", "面"), ("Stacked Area", "積み上げ面"), + ("100% Stacked Area", "100% 積み上げ面"), ("Scatter", "散布図"), ("Scatter with Lines", "直線付き散布図"), ("Radar", "レーダー"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 198d7f197..fd4a3b307 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "화살표 제거", "Show Formulas" => "수식 표시", "Error Checking" => "오류 검사", + "Circular References" => "순환 참조", "Evaluate Formula" => "수식 계산", "Watch Window" => "감시 창", "Calculation\nOptions" => "계산\n옵션", @@ -336,6 +337,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "머리글로 이동", "Different First Page" => "첫 페이지를 다르게 지정", "Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정", + "Scale with Document" => "문서에 맞게 배율 조정", + "Align with Page Margins" => "페이지 여백에 맞추기", "Go to Footer" => "바닥글로 이동", "Ruler" => "눈금자", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 03d392675..3ed2e8ef1 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pijlen verwijderen", "Show Formulas" => "Formules weergeven", "Error Checking" => "Foutopsporing", + "Circular References" => "Kringverwijzingen", "Evaluate Formula" => "Formule evalueren", "Watch Window" => "Bewakingsvenster", "Calculation\nOptions" => "Berekenings-\nopties", @@ -330,6 +331,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Naar koptekst", "Different First Page" => "Eerste pagina afwijkend", "Different Odd & Even Pages" => "Even en oneven pagina's afwijkend", + "Scale with Document" => "Meeschalen met document", + "Align with Page Margins" => "Uitlijnen op paginamarges", "Go to Footer" => "Naar voettekst", "Ruler" => "Liniaal", "File" => "Bestand", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 3bf64c190..ed68e823e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Usuń strzałki", "Show Formulas" => "Pokaż formuły", "Error Checking" => "Sprawdzanie błędów", + "Circular References" => "Odwołania cykliczne", "Evaluate Formula" => "Szacuj formułę", "Watch Window" => "Okno czujki", "Calculation\nOptions" => "Opcje\nobliczania", @@ -326,6 +327,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Przejdź do nagłówka", "Different First Page" => "Inna pierwsza strona", "Different Odd & Even Pages" => "Inne strony nieparzyste i parzyste", + "Scale with Document" => "Skaluj z dokumentem", + "Align with Page Margins" => "Wyrównaj do marginesów strony", "Go to Footer" => "Przejdź do stopki", "Ruler" => "Linijka", "File" => "Plik", @@ -759,6 +762,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Pierścieniowy", "Area" => "Warstwowy", "Stacked Area" => "Warstwowy skumulowany", + "100% Stacked Area" => "Warstwowy 100% skumulowany", "Scatter" => "Punktowy", "Scatter with Lines" => "Punktowy z liniami", "Radar" => "Radarowy", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index a58954fcf..706f19510 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Remover Setas", "Show Formulas" => "Exibir Fórmulas", "Error Checking" => "Verificação de Erros", + "Circular References" => "Referências Circulares", "Evaluate Formula" => "Avaliar Fórmula", "Watch Window" => "Janela de Inspeção", "Calculation\nOptions" => "Opções de\nCálculo", @@ -332,6 +333,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Ir para o Cabeçalho", "Different First Page" => "Primeira Página Diferente", "Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes", + "Scale with Document" => "Dimensionar com o Documento", + "Align with Page Margins" => "Alinhar com as Margens da Página", "Go to Footer" => "Ir para o Rodapé", "Ruler" => "Régua", // File › Backstage view. @@ -776,6 +779,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Rosca", "Area" => "Área", "Stacked Area" => "Área Empilhada", + "100% Stacked Area" => "Área 100% Empilhada", "Scatter" => "Dispersão", "Scatter with Lines" => "Dispersão com Linhas", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 2ddaebde9..480c802e8 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Eliminare săgeți", "Show Formulas" => "Afișare formule", "Error Checking" => "Verificare erori", + "Circular References" => "Referințe circulare", "Evaluate Formula" => "Evaluare formulă", "Watch Window" => "Fereastră de urmărire", "Calculation\nOptions" => "Opțiuni de\ncalcul", @@ -333,6 +334,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Salt la antet", "Different First Page" => "Prima pagină diferită", "Different Odd & Even Pages" => "Pagini pare și impare diferite", + "Scale with Document" => "Scalare cu documentul", + "Align with Page Margins" => "Aliniere la marginile paginii", "Go to Footer" => "Salt la subsol", "Ruler" => "Riglă", "File" => "Fișier", @@ -765,6 +768,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Inel", "Area" => "Arie", "Stacked Area" => "Arie stivuită", + "100% Stacked Area" => "Arie 100% stivuită", "Scatter" => "Dispersie", "Scatter with Lines" => "Dispersie cu linii", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 03d938d58..7e547eff4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Убрать стрелки", "Show Formulas" => "Показать формулы", "Error Checking" => "Проверка ошибок", + "Circular References" => "Циклические ссылки", "Evaluate Formula" => "Вычислить формулу", "Watch Window" => "Окно контрольных значений", "Calculation\nOptions" => "Параметры\nвычислений", @@ -336,6 +337,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Перейти к верхнему колонтитулу", "Different First Page" => "Особый колонтитул для первой страницы", "Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц", + "Scale with Document" => "Масштабировать вместе с документом", + "Align with Page Margins" => "Выровнять по полям страницы", "Go to Footer" => "Перейти к нижнему колонтитулу", "Ruler" => "Линейка", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 84ffd579b..d1b6d7d2c 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Ukloni strelice", "Show Formulas" => "Prikaži formule", "Error Checking" => "Provera grešaka", + "Circular References" => "Kružne reference", "Evaluate Formula" => "Proceni formulu", "Watch Window" => "Prozor za praćenje", "Calculation\nOptions" => "Opcije\nizračunavanja", @@ -329,6 +330,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Idi na zaglavlje", "Different First Page" => "Drugačija prva stranica", "Different Odd & Even Pages" => "Drugačije neparne i parne stranice", + "Scale with Document" => "Skaliraj sa dokumentom", + "Align with Page Margins" => "Poravnaj sa marginama stranice", "Go to Footer" => "Idi na podnožje", "Ruler" => "Lenjir", "File" => "Datoteka", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index d362d517c..a97245eaa 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -144,6 +144,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Okları Kaldır", "Show Formulas" => "Formülleri Göster", "Error Checking" => "Hata Denetimi", + "Circular References" => "Döngüsel Başvurular", "Evaluate Formula" => "Formülü Değerlendir", "Watch Window" => "Gözcü Penceresi", "Calculation\nOptions" => "Hesaplama\nSeçenekleri", @@ -208,6 +209,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Altbilgi", "Different First Page" => "İlk Sayfada Farklı", "Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı", + "Scale with Document" => "Belgeyle Ölçeklendir", + "Align with Page Margins" => "Sayfa Kenar Boşluklarıyla Hizala", "Go to Footer" => "Altbilgiye Git", "Go to Header" => "Üstbilgiye Git", "Go to\nFooter" => "Altbilgiye\nGit", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f62bceb3a..4a89c227c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Прибрати стрілки", "Show Formulas" => "Показати формули", "Error Checking" => "Перевірка помилок", + "Circular References" => "Циклічні посилання", "Evaluate Formula" => "Обчислити формулу", "Watch Window" => "Окно контрольних значень", "Calculation\nOptions" => "Параметри\nобчислень", @@ -329,6 +330,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Перейти до верхнього колонтитула", "Different First Page" => "Окремий колонтитул для першої сторінки", "Different Odd & Even Pages" => "Різні колонтитули для парних і непарних сторінок", + "Scale with Document" => "Масштабувати разом із документом", + "Align with Page Margins" => "Вирівняти за полями сторінки", "Go to Footer" => "Перейти до нижнього колонтитула", "Ruler" => "Лінійка", // File › Backstage view. @@ -762,6 +765,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Doughnut", "Area" => "Area", "Stacked Area" => "Stacked Area", + "100% Stacked Area" => "100% Stacked Area", "Scatter" => "Scatter", "Scatter with Lines" => "Scatter with Lines", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 794061b0c..68ca4d2b4 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Bỏ mũi tên", "Show Formulas" => "Hiện công thức", "Error Checking" => "Kiểm tra lỗi", + "Circular References" => "Tham chiếu vòng", "Evaluate Formula" => "Đánh giá công thức", "Watch Window" => "Cửa sổ theo dõi", "Calculation\nOptions" => "Tùy chọn\ntính toán", @@ -334,6 +335,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Đi tới đầu trang", "Different First Page" => "Trang đầu khác", "Different Odd & Even Pages" => "Trang chẵn lẻ khác nhau", + "Scale with Document" => "Co giãn theo tài liệu", + "Align with Page Margins" => "Căn theo lề trang", "Go to Footer" => "Đi tới chân trang", "Ruler" => "Thước", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 8ad508300..9d22d98b4 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "删除箭头", "Show Formulas" => "显示公式", "Error Checking" => "错误检查", + "Circular References" => "循环引用", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", "Calculation\nOptions" => "计算\n选项", @@ -334,6 +335,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "转至页眉", "Different First Page" => "首页不同", "Different Odd & Even Pages" => "奇偶页不同", + "Scale with Document" => "随文档缩放", + "Align with Page Margins" => "与页边距对齐", "Go to Footer" => "转至页脚", "Ruler" => "标尺", // File › Backstage view. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 60ba3cd11..385ed076b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1,9 +1,13 @@ //! GridCraft's icon set, drawn in code on a 16×16 design grid (original artwork; see //! ATTRIBUTION.md). Large ribbon icons use the same drawings at 32×32 with accent colours. +//! Accent and paper colours come from the theme tokens, so icons follow light and dark mode; +//! icons placed on the sheet ([`paint_on_sheet`]) keep the light colours, as the sheet does. use egui::epaint::{CubicBezierShape, PathShape, PathStroke}; use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2}; +use crate::theme::{Cat, Tokens}; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Icon { Paste, @@ -184,13 +188,28 @@ impl G<'_> { } } -/// Accent palette for icons (our own). -pub const GREEN: Color32 = Color32::from_rgb(0x10, 0x7C, 0x41); -pub const BLUE: Color32 = Color32::from_rgb(0x2B, 0x7C, 0xD3); -pub const RED: Color32 = Color32::from_rgb(0xD1, 0x3B, 0x2F); -pub const YELLOW: Color32 = Color32::from_rgb(0xF2, 0xC3, 0x2E); -pub const ORANGE: Color32 = Color32::from_rgb(0xE8, 0x7D, 0x2B); -pub const PURPLE: Color32 = Color32::from_rgb(0x88, 0x55, 0xC8); +/// The colours an icon draws with besides its main colour: the paper inside documents and cells, +/// and the category palette for its coloured parts. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Palette { + paper: Color32, + cat: [Color32; 8], +} + +impl Palette { + fn of(t: &Tokens) -> Palette { + Palette { paper: t.paper, cat: t.cat } + } + + fn cat(&self, c: Cat) -> Color32 { + self.cat.get(c as usize).copied().unwrap_or(Color32::GRAY) + } + + /// A pale wash of a category colour over the paper (cell fills inside icons). + fn wash(&self, c: Cat) -> Color32 { + self.cat(c).gamma_multiply(0.3) + } +} /// The code-drawn icon that stands in for a character the current fonts cannot draw (`✕` and /// `✓` have no bundled egui face). Interface chrome uses this instead of painting a tofu box; a @@ -203,17 +222,34 @@ pub fn fallback_for_symbol(c: char) -> Option { } } -/// Draws `icon` into `rect` (square-ish), in colour `c`. +/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette. pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { + paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx()))); +} + +/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette. +pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) { + paint_with(p, rect, icon, c, &Palette::of(&Tokens::light())); +} + +/// Draws `icon` into `rect` (square-ish), in colour `c`, with palette `pal`. +fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { let side = rect.width().min(rect.height()); let r = Rect::from_center_size(rect.center(), vec2(side, side)); let g = G { p, r, s: side / 16.0 }; let light = c.gamma_multiply(0.45); + let paper = pal.paper; + let blue = pal.cat(Cat::Blue); + let green = pal.cat(Cat::Green); + let red = pal.cat(Cat::Red); + let orange = pal.cat(Cat::Orange); + let purple = pal.cat(Cat::Purple); + let yellow = pal.cat(Cat::Amber); match icon { Icon::Paste => { - g.rect(3.0, 2.5, 9.0, 12.0, Some(Color32::from_rgb(0xC8, 0x9B, 0x5C)), None); - g.rect(5.0, 1.5, 5.0, 2.5, Some(Color32::from_rgb(0x8A, 0x8A, 0x8A)), None); - g.rect(6.5, 6.0, 7.5, 9.0, Some(Color32::WHITE), Some(c)); + g.rect(3.0, 2.5, 9.0, 12.0, Some(yellow), None); + g.rect(5.0, 1.5, 5.0, 2.5, Some(pal.cat(Cat::Slate)), None); + g.rect(6.5, 6.0, 7.5, 9.0, Some(paper), Some(c)); for y in [8.5, 10.5, 12.5] { g.stroke(&[(8.0, y), (12.5, y)], light); } @@ -225,11 +261,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.stroke(&[(9.7, 10.6), (4.5, 2.0)], c); } Icon::Copy => { - g.rect(2.5, 2.5, 7.5, 9.0, Some(Color32::WHITE), Some(c)); - g.rect(6.0, 5.5, 7.5, 9.0, Some(Color32::WHITE), Some(c)); + g.rect(2.5, 2.5, 7.5, 9.0, Some(paper), Some(c)); + g.rect(6.0, 5.5, 7.5, 9.0, Some(paper), Some(c)); } Icon::Brush => { - g.rect(3.0, 2.0, 10.0, 4.0, Some(YELLOW), Some(c)); + g.rect(3.0, 2.0, 10.0, 4.0, Some(yellow), Some(c)); g.stroke(&[(8.0, 6.0), (8.0, 8.5), (5.0, 8.5), (5.0, 10.0)], c); g.rect(4.0, 10.0, 2.0, 4.5, Some(c), None); } @@ -270,7 +306,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.line(&[(2.5, 2.5), (13.5, 2.5), (13.5, 13.5), (2.5, 13.5), (2.5, 2.5)], c, if icon == Icon::BorderThick { 2.2 } else { 1.2 }); } Icon::Fill => { - g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], Color32::WHITE); + g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], paper); g.line(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0), (3.0, 7.0)], c, 1.1); g.circle(13.5, 10.0, 1.2, Some(c), None); } @@ -308,16 +344,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.stroke(&[(x0, *y), (x1, *y)], c); } if icon == Icon::IndentInc { - g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], BLUE); + g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], blue); } if icon == Icon::IndentDec { - g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], BLUE); + g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], blue); } } Icon::Orientation => { g.text(6.0, 9.0, "ab", 8.5, c, false, false); - g.stroke(&[(9.0, 13.0), (14.0, 3.0)], BLUE); - g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], BLUE); + g.stroke(&[(9.0, 13.0), (14.0, 3.0)], blue); + g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], blue); } Icon::Wrap => { g.stroke(&[(2.0, 4.0), (14.0, 4.0)], c); @@ -326,16 +362,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { [g.pt(12.0, 8.0), g.pt(15.0, 8.0), g.pt(15.0, 12.0), g.pt(11.0, 12.0)], false, Color32::TRANSPARENT, - Stroke::new(1.1, BLUE), + Stroke::new(1.1, blue), )); - g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], BLUE); + g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], blue); g.stroke(&[(2.0, 12.0), (7.0, 12.0)], c); } Icon::Merge => { g.rect(1.5, 3.5, 13.0, 9.0, None, Some(c)); - g.stroke(&[(4.0, 8.0), (12.0, 8.0)], BLUE); - g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], BLUE); - g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], BLUE); + g.stroke(&[(4.0, 8.0), (12.0, 8.0)], blue); + g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], blue); + g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], blue); } Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, false, false), Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, false, false), @@ -343,69 +379,69 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::DecInc => { g.text(5.0, 5.0, ".0", 6.5, c, false, false); g.text(10.0, 11.0, ".00", 6.5, c, false, false); - g.stroke(&[(2.0, 12.0), (5.0, 12.0)], BLUE); - g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], BLUE); + g.stroke(&[(2.0, 12.0), (5.0, 12.0)], blue); + g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], blue); } Icon::DecDec => { g.text(6.0, 5.0, ".00", 6.5, c, false, false); g.text(11.0, 11.0, ".0", 6.5, c, false, false); - g.stroke(&[(2.0, 12.0), (5.5, 12.0)], BLUE); - g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], BLUE); + g.stroke(&[(2.0, 12.0), (5.5, 12.0)], blue); + g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], blue); } Icon::CondFormat => { - g.rect(2.0, 2.0, 12.0, 12.0, Some(Color32::WHITE), Some(light)); - g.rect(3.0, 3.5, 4.0, 2.5, Some(RED), None); - g.rect(3.0, 7.0, 7.0, 2.5, Some(YELLOW), None); - g.rect(3.0, 10.5, 10.0, 2.5, Some(BLUE), None); + g.rect(2.0, 2.0, 12.0, 12.0, Some(paper), Some(light)); + g.rect(3.0, 3.5, 4.0, 2.5, Some(red), None); + g.rect(3.0, 7.0, 7.0, 2.5, Some(yellow), None); + g.rect(3.0, 10.5, 10.0, 2.5, Some(blue), None); } Icon::FormatTable => { - g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(light)); - g.rect(2.0, 2.5, 12.0, 3.0, Some(BLUE), None); - g.rect(2.0, 8.0, 12.0, 2.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None); + g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(light)); + g.rect(2.0, 2.5, 12.0, 3.0, Some(blue), None); + g.rect(2.0, 8.0, 12.0, 2.5, Some(pal.wash(Cat::Blue)), None); g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light); g.stroke(&[(10.0, 2.5), (10.0, 13.5)], light); } Icon::CellStyles => { - g.rect(2.0, 3.0, 12.0, 4.5, Some(Color32::from_rgb(0xC6, 0xEF, 0xCE)), Some(light)); - g.rect(2.0, 8.5, 12.0, 4.5, Some(Color32::from_rgb(0xFF, 0xC7, 0xCE)), Some(light)); - g.stroke(&[(4.0, 5.2), (9.0, 5.2)], GREEN); - g.stroke(&[(4.0, 10.7), (9.0, 10.7)], RED); + g.rect(2.0, 3.0, 12.0, 4.5, Some(pal.wash(Cat::Green)), Some(light)); + g.rect(2.0, 8.5, 12.0, 4.5, Some(pal.wash(Cat::Red)), Some(light)); + g.stroke(&[(4.0, 5.2), (9.0, 5.2)], green); + g.stroke(&[(4.0, 10.7), (9.0, 10.7)], red); } Icon::Insert | Icon::Delete | Icon::Format => { - g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::WHITE), Some(light)); + g.rect(2.0, 3.0, 12.0, 10.0, Some(paper), Some(light)); g.stroke(&[(2.0, 6.5), (14.0, 6.5)], light); g.stroke(&[(2.0, 10.0), (14.0, 10.0)], light); g.stroke(&[(6.0, 3.0), (6.0, 13.0)], light); match icon { Icon::Insert => { - g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(BLUE)); - g.line(&[(1.0, 8.2), (4.5, 8.2)], GREEN, 1.6); - g.line(&[(2.75, 6.5), (2.75, 10.0)], GREEN, 1.6); + g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), Some(blue)); + g.line(&[(1.0, 8.2), (4.5, 8.2)], green, 1.6); + g.line(&[(2.75, 6.5), (2.75, 10.0)], green, 1.6); } Icon::Delete => { - g.line(&[(8.0, 6.0), (13.0, 11.0)], RED, 1.6); - g.line(&[(13.0, 6.0), (8.0, 11.0)], RED, 1.6); + g.line(&[(8.0, 6.0), (13.0, 11.0)], red, 1.6); + g.line(&[(13.0, 6.0), (8.0, 11.0)], red, 1.6); } _ => { - g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None); + g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), None); } } } Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, false, false), Icon::FillDown => { - g.rect(4.0, 2.0, 8.0, 12.0, Some(Color32::WHITE), Some(light)); - g.stroke(&[(8.0, 4.0), (8.0, 11.0)], BLUE); - g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], BLUE); + g.rect(4.0, 2.0, 8.0, 12.0, Some(paper), Some(light)); + g.stroke(&[(8.0, 4.0), (8.0, 11.0)], blue); + g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], blue); } Icon::Eraser => { - g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], Color32::from_rgb(0xE9, 0x8A, 0xB8)); - g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], Color32::from_rgb(0xB6, 0x4E, 0x86)); + g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], red.gamma_multiply(0.55)); + g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], red); } Icon::SortFilter => { g.text(4.5, 4.5, "A", 6.0, c, false, false); g.text(4.5, 11.5, "Z", 6.0, c, false, false); - g.stroke(&[(8.0, 3.0), (8.0, 13.0)], BLUE); - g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], BLUE); + g.stroke(&[(8.0, 3.0), (8.0, 13.0)], blue); + g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], blue); g.poly(&[(10.0, 3.0), (15.0, 3.0), (13.0, 7.5), (13.0, 12.0), (12.0, 11.0), (12.0, 7.5)], light); } Icon::Find | Icon::Search | Icon::Zoom => { @@ -477,45 +513,45 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { } Icon::PageBreak => { g.rect(2.5, 2.0, 11.0, 12.0, None, Some(c)); - g.line(&[(2.5, 8.0), (13.5, 8.0)], BLUE, 1.6); + g.line(&[(2.5, 8.0), (13.5, 8.0)], blue, 1.6); } Icon::Table | Icon::PivotTable => { - g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(c)); - g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { BLUE } else { GREEN }), None); + g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(c)); + g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { blue } else { green }), None); g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light); g.stroke(&[(2.0, 9.5), (14.0, 9.5)], light); if icon == Icon::PivotTable { - g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], ORANGE); + g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], orange); } } Icon::Chart | Icon::ChartBar => { g.stroke(&[(2.0, 14.0), (14.0, 14.0)], c); - g.rect(3.0, 8.0, 2.5, 6.0, Some(BLUE), None); - g.rect(6.8, 4.0, 2.5, 10.0, Some(GREEN), None); - g.rect(10.6, 6.0, 2.5, 8.0, Some(ORANGE), None); + g.rect(3.0, 8.0, 2.5, 6.0, Some(blue), None); + g.rect(6.8, 4.0, 2.5, 10.0, Some(green), None); + g.rect(10.6, 6.0, 2.5, 8.0, Some(orange), None); } Icon::ChartLine | Icon::Sparkline => { g.stroke(&[(2.0, 14.0), (14.0, 14.0)], light); - g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], BLUE, 1.5); + g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], blue, 1.5); } Icon::ChartPie => { - g.circle(8.0, 8.0, 6.0, Some(BLUE), None); - g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], ORANGE); + g.circle(8.0, 8.0, 6.0, Some(blue), None); + g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], orange); } Icon::ChartScatter => { g.stroke(&[(2.0, 2.0), (2.0, 14.0), (14.0, 14.0)], c); for (x, y) in [(5.0, 11.0), (7.0, 8.0), (9.5, 9.5), (11.0, 5.0), (13.0, 4.0)] { - g.circle(x, y, 1.1, Some(BLUE), None); + g.circle(x, y, 1.1, Some(blue), None); } } Icon::Picture => { - g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(c)); - g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], GREEN); - g.circle(11.0, 5.5, 1.2, Some(YELLOW), None); + g.rect(2.0, 3.0, 12.0, 10.0, Some(pal.wash(Cat::Blue)), Some(c)); + g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], green); + g.circle(11.0, 5.5, 1.2, Some(yellow), None); } Icon::Shapes => { - g.circle(5.5, 5.5, 3.5, Some(BLUE), None); - g.rect(7.0, 7.0, 7.0, 7.0, Some(ORANGE), None); + g.circle(5.5, 5.5, 3.5, Some(blue), None); + g.rect(7.0, 7.0, 7.0, 7.0, Some(orange), None); } Icon::Link => { g.line(&[(6.0, 10.0), (10.0, 6.0)], c, 1.2); @@ -523,10 +559,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.rect(8.0, 4.0, 6.0, 4.0, None, Some(c)); } Icon::Comment | Icon::Note => { - g.poly( - &[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)], - if icon == Icon::Note { Color32::from_rgb(0xFF, 0xF2, 0xA8) } else { Color32::WHITE }, - ); + g.poly(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)], if icon == Icon::Note { pal.wash(Cat::Amber) } else { paper }); g.line(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (7.0, 11.0), (4.0, 14.0), (4.0, 11.0), (2.0, 11.0), (2.0, 3.0)], c, 1.1); } Icon::TextBox => { @@ -540,10 +573,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.text(8.0, 8.0, "ab", 8.0, c, false, false); } Icon::Trace => { - g.circle(4.0, 12.0, 1.6, Some(BLUE), None); - g.circle(12.0, 4.0, 1.6, Some(BLUE), None); - g.line(&[(5.0, 11.0), (11.0, 5.0)], BLUE, 1.2); - g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], BLUE); + g.circle(4.0, 12.0, 1.6, Some(blue), None); + g.circle(12.0, 4.0, 1.6, Some(blue), None); + g.line(&[(5.0, 11.0), (11.0, 5.0)], blue, 1.2); + g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], blue); } Icon::Calc => { g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c)); @@ -557,23 +590,23 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { let (a, b) = if icon == Icon::SortAsc { ("A", "Z") } else { ("Z", "A") }; g.text(5.0, 4.5, a, 6.5, c, false, false); g.text(5.0, 11.5, b, 6.5, c, false, false); - g.stroke(&[(11.0, 2.5), (11.0, 13.0)], BLUE); - g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], BLUE); + g.stroke(&[(11.0, 2.5), (11.0, 13.0)], blue); + g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], blue); } Icon::Duplicates => { - g.rect(2.0, 2.0, 9.0, 4.0, Some(Color32::WHITE), Some(c)); - g.rect(5.0, 7.0, 9.0, 4.0, Some(Color32::WHITE), Some(c)); - g.line(&[(9.0, 12.0), (14.0, 15.0)], RED, 1.4); - g.line(&[(14.0, 12.0), (9.0, 15.0)], RED, 1.4); + g.rect(2.0, 2.0, 9.0, 4.0, Some(paper), Some(c)); + g.rect(5.0, 7.0, 9.0, 4.0, Some(paper), Some(c)); + g.line(&[(9.0, 12.0), (14.0, 15.0)], red, 1.4); + g.line(&[(14.0, 12.0), (9.0, 15.0)], red, 1.4); } Icon::TextColumns => { g.rect(1.5, 3.0, 5.5, 10.0, None, Some(c)); g.rect(9.0, 3.0, 5.5, 10.0, None, Some(c)); - g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], BLUE); + g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], blue); } Icon::Validation => { g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c)); - g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], GREEN, 1.4); + g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], green, 1.4); } Icon::Group | Icon::Ungroup | Icon::Subtotal => { g.stroke(&[(3.0, 3.0), (3.0, 13.0), (6.0, 13.0)], c); @@ -581,59 +614,59 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.stroke(&[(7.0, y), (14.0, y)], light); } match icon { - Icon::Group => g.text(3.0, 2.5, "+", 6.0, BLUE, true, false), - Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, BLUE, true, false), - _ => g.text(11.0, 13.0, "Σ", 6.0, BLUE, false, false), + Icon::Group => g.text(3.0, 2.5, "+", 6.0, blue, true, false), + Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, blue, true, false), + _ => g.text(11.0, 13.0, "Σ", 6.0, blue, false, false), } } Icon::Lock => { - g.rect(3.5, 7.0, 9.0, 7.0, Some(YELLOW), Some(c)); + g.rect(3.5, 7.0, 9.0, 7.0, Some(yellow), Some(c)); g.line(&[(5.5, 7.0), (5.5, 4.5), (8.0, 2.5), (10.5, 4.5), (10.5, 7.0)], c, 1.2); } Icon::Spell => { g.text(7.0, 6.0, "abc", 7.0, c, false, false); - g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], GREEN, 1.4); + g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], green, 1.4); } Icon::Freeze => { g.rect(2.0, 2.5, 12.0, 11.0, None, Some(c)); - g.rect(2.0, 2.5, 12.0, 3.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None); - g.rect(2.0, 2.5, 3.5, 11.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None); - g.line(&[(2.0, 5.5), (14.0, 5.5)], BLUE, 1.2); - g.line(&[(5.5, 2.5), (5.5, 13.5)], BLUE, 1.2); + g.rect(2.0, 2.5, 12.0, 3.0, Some(pal.wash(Cat::Blue)), None); + g.rect(2.0, 2.5, 3.5, 11.0, Some(pal.wash(Cat::Blue)), None); + g.line(&[(2.0, 5.5), (14.0, 5.5)], blue, 1.2); + g.line(&[(5.5, 2.5), (5.5, 13.5)], blue, 1.2); } Icon::Theme => { - g.rect(2.0, 2.0, 5.5, 5.5, Some(BLUE), None); - g.rect(8.5, 2.0, 5.5, 5.5, Some(ORANGE), None); - g.rect(2.0, 8.5, 5.5, 5.5, Some(GREEN), None); - g.rect(8.5, 8.5, 5.5, 5.5, Some(PURPLE), None); + g.rect(2.0, 2.0, 5.5, 5.5, Some(blue), None); + g.rect(8.5, 2.0, 5.5, 5.5, Some(orange), None); + g.rect(2.0, 8.5, 5.5, 5.5, Some(green), None); + g.rect(8.5, 8.5, 5.5, 5.5, Some(purple), None); } Icon::Margins => { g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c)); - g.rect(5.0, 3.5, 6.0, 9.0, None, Some(BLUE)); + g.rect(5.0, 3.5, 6.0, 9.0, None, Some(blue)); } Icon::Orient => { g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c)); - g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE)); + g.rect(6.0, 2.0, 8.0, 6.0, Some(paper), Some(blue)); } Icon::SheetRtl => { // A sheet with an arrow running right to left. g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c)); g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light); g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light); - g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4); - g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE); + g.line(&[(13.5, 13.0), (4.5, 13.0)], blue, 1.4); + g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], blue); } Icon::PrintArea => { g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light)); - g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4); + g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], blue, 1.4); } Icon::Script => { g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c)); - g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], GREEN, 1.3); + g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], green, 1.3); g.stroke(&[(8.0, 10.5), (11.5, 10.5)], c); } Icon::Pen => { - g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], BLUE); + g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], blue); g.stroke(&[(3.0, 13.0), (4.0, 10.0)], c); } Icon::Share => { @@ -656,18 +689,18 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.poly(&half, c); } Icon::Book => { - g.rect(2.0, 2.0, 12.0, 12.0, Some(GREEN), None); - g.rect(4.0, 4.0, 8.0, 8.0, Some(Color32::WHITE), None); - g.stroke(&[(8.0, 4.0), (8.0, 12.0)], GREEN); - g.stroke(&[(4.0, 8.0), (12.0, 8.0)], GREEN); + g.rect(2.0, 2.0, 12.0, 12.0, Some(green), None); + g.rect(4.0, 4.0, 8.0, 8.0, Some(paper), None); + g.stroke(&[(8.0, 4.0), (8.0, 12.0)], green); + g.stroke(&[(4.0, 8.0), (12.0, 8.0)], green); } Icon::Folder => { - g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW); + g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow); } Icon::Header | Icon::Footer => { g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c)); let y = if icon == Icon::Header { 2.5 } else { 11.0 }; - g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None); + g.rect(4.5, y, 7.0, 2.5, Some(blue), None); for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } { g.stroke(&[(5.5, yy), (10.5, yy)], light); } @@ -676,16 +709,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { if icon == Icon::PageCount { g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light)); } - g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c)); + g.rect(2.5, 3.0, 8.5, 12.0, Some(paper), Some(c)); // A "#" mark. - g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE); - g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE); - g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE); - g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE); + g.stroke(&[(5.3, 6.5), (4.8, 12.5)], blue); + g.stroke(&[(8.3, 6.5), (7.8, 12.5)], blue); + g.stroke(&[(4.0, 8.4), (9.6, 8.4)], blue); + g.stroke(&[(3.8, 10.8), (9.4, 10.8)], blue); } Icon::Calendar => { - g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c)); - g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None); + g.rect(2.0, 3.0, 12.0, 11.0, Some(paper), Some(c)); + g.rect(2.0, 3.0, 12.0, 3.0, Some(blue), None); for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] { g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None); } @@ -693,13 +726,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::Clock => { let center = g.pt(8.0, 8.0); g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c)); - g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE); + g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], blue); } Icon::FileName => { - g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE); + g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], paper); g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15); g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c); - g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4); + g.line(&[(5.0, 9.0), (11.0, 9.0)], blue, 1.4); g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light); } Icon::Back => { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5840036a1..54058dd71 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -103,6 +103,8 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio L if (cmd || ctrl) && s => run("data.filter"), L if cmd && mac => run("home.alignLeft"), L if ctrl && mac => Shortcut::Dialog("defineName", json!({})), + // Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands or collapses the formula bar. + U if ctrl && s => run("view.expandFormulaBar"), U if ctrl && mac && !s => Shortcut::Edit, L if cmd => run("insert.table"), E if cmd && mac && !s => run("home.alignCenter"), @@ -157,6 +159,7 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})), F3 if cmd => Shortcut::Dialog("nameManager", json!({})), F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})), + F3 if plain => Shortcut::Dialog("pasteName", json!({"shortcut": true})), F4 if cmd && !s => run("file.close"), F5 if plain && s => Shortcut::Dialog("find", json!({})), F5 if plain => Shortcut::Dialog("goTo", json!({})), diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index d94128de7..c35a3bbad 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -243,7 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents ("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions) ("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula) - ("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands + ("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names ("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text) ("MU", Entry::Continue), // Excel: AutoSum Menu; group level: ("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 96bdc075a..5be64f867 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1666,11 +1666,10 @@ }, { "seq": "MSP", - "kind": "command", - "target": "formulas.pasteName", + "kind": "dialog", + "target": "pasteName", "excel": "Paste Names", - "note": "reserved until the command lands", - "pending": true + "note": "" }, { "seq": "MT", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47799b6b5..03f390f2e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod backstage; mod border_preview; mod cell_pictures; pub mod chartview; +pub mod code_editor; pub mod control; mod craft_fonts_policy; pub mod credits; @@ -674,6 +675,10 @@ impl SheetApp { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; Ok(Json::Null) } + "view.expandFormulaBar" => { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } "view.darkMode" => { let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); @@ -790,6 +795,7 @@ impl SheetApp { }); self.begin_edit(text, false); } + UiRequest::InsertName(name) => dialogs::insert_name(self, &name), UiRequest::OpenUrl(u) => { if let Some(f) = &self.services.open_url { f(&u); diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 19fd88497..9096d1611 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -1,7 +1,8 @@ //! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the //! row headers and one above the column headers, one lane per outline level, with a bracket per -//! expanded group, a − / + button on its summary line (below / right of the detail) and the level -//! buttons 1…n+1 in the corner. The bands exist only while the sheet has an outline. +//! expanded group, a − / + button on its summary line (below / right of the detail, or above / +//! left of it when the sheet's outline settings say so) and the level buttons 1…n+1 in the +//! corner. The bands exist only while the sheet has an outline. //! //! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates, //! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands @@ -31,6 +32,12 @@ impl Axis { pub fn new(lines: &BTreeMap) -> Axis { Axis { levels: outline::max_level(lines), groups: outline::groups(lines) } } + /// The outline of a sheet's rows (`rows`) or columns, with its summary lines where the + /// sheet's outline settings put them. + pub fn of(sh: &Sheet, rows: bool) -> Axis { + let lines = if rows { &sh.rows } else { &sh.cols }; + Axis { levels: outline::max_level(lines), groups: outline::groups_with(lines, outline::summary_before(sh, rows)) } + } } /// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%). @@ -197,7 +204,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { for grp in &axis.groups { let across = dir.lane(step, grp.level); // The button on the summary line, if that line is on screen. - let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.summary() > grp.end); + let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.has_summary()); if let Some(mid) = button { out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } }); } @@ -207,6 +214,18 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { // The bracket, over the part of the detail that is on screen. let first = dir.first_visible(grp.start, grp.end); let Some((first_line, (a0, _))) = first else { continue }; + if grp.summary_before { + // From the button down (or across) to the last detail line, with the tick there. + let a0 = button.map_or(a0, |mid| mid + side / 2.0); + let end = dir.start(grp.end) + dir.size(grp.end); + let a1 = end.min(dir.hi); + if a1 <= a0 { + continue; + } + let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]); + out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick }); + continue; + } let a1 = match button { Some(mid) => mid - side / 2.0, None => dir.start(grp.summary()).min(dir.hi), @@ -381,6 +400,41 @@ mod tests { assert_eq!(outer.action.label(), "\u{2212}"); } + #[test] + fn summaries_above_and_left_take_the_toggles_and_the_bracket_ticks_move_to_the_end() { + let mut sh = sheet(); + sh.summary_below = false; + sh.summary_right = false; + let g = geo(&sh); + let l = layout(&g, &sh); + let toggle = |rows: bool, level: u8| { + l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: r, group } if r == rows && group.level == level)).unwrap() + }; + // Rows 5:8: the outer − is on row 4 (index 3), the inner one (rows 6:7) on row 5. + let outer = toggle(true, 1); + assert!(g.cell_rect(&sh, CellRef::new(3, 0)).y_range().contains(outer.rect.center().y)); + assert!(g.cell_rect(&sh, CellRef::new(4, 0)).y_range().contains(toggle(true, 2).rect.center().y)); + // Column group C:E: its − is above column B. + let col = toggle(false, 1); + assert!(g.cell_rect(&sh, CellRef::new(0, 1)).x_range().contains(col.rect.center().x)); + // The bracket runs from the button to the end of row 8, with the tick there. + assert_eq!(l.brackets.len(), 3); + let b = l.brackets.iter().find(|b| (b.line[0].x - outer.rect.center().x).abs() < 0.01).unwrap(); + let row8 = g.cell_rect(&sh, CellRef::new(7, 0)); + assert!((b.line[0].y - outer.rect.bottom()).abs() < 0.01 && (b.line[1].y - (row8.bottom() - 0.5)).abs() < 1.0); + assert!(b.tick.is_some_and(|t| (t[0].y - b.line[1].y).abs() < 0.01 && t[1].x > t[0].x)); + // A group on the first row has no row above it: no toggle, still a bracket. + let mut sh = Sheet::new("S"); + sh.summary_below = false; + for r in 0..=2 { + sh.rows.entry(r).or_default().outline = 1; + } + let g = geo(&sh); + let l = layout(&g, &sh); + assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { .. }))); + assert_eq!(l.brackets.len(), 1); + } + #[test] fn collapsed_groups_show_plus_and_no_bracket() { let mut sh = sheet(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 8a7486431..81ccd3b96 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -57,12 +57,13 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { if list.is_empty() { ui.label(egui::RichText::new("No comments on this sheet yet. Select a cell and choose New Comment.").italics()); } + let t = crate::theme::Tokens::get(ui.ctx()); for (cell, c) in list { egui::Frame::NONE - .fill(if c.resolved { Color32::from_gray(240) } else { Color32::WHITE }) + .fill(if c.resolved { t.window } else { t.menu_bg }) .corner_radius(6.0) .inner_margin(8.0) - .stroke(egui::Stroke::new(1.0, Color32::from_gray(215))) + .stroke(egui::Stroke::new(1.0, t.separator)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal_wrapped(|ui| { @@ -70,14 +71,14 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { let _ = app.run("selection.set", json!({"cell": cell})); app.grid.ensure_visible = true; } - ui.label(egui::RichText::new(&c.author).color(Color32::from_gray(90))); + ui.label(egui::RichText::new(&c.author).color(t.text_dim)); if c.resolved { - ui.label(egui::RichText::new("Resolved").small().color(Color32::from_rgb(0x10, 0x7C, 0x41))); + ui.label(egui::RichText::new("Resolved").small().color(t.accent)); } }); ui.label(&c.text); for (a, txt) in &c.replies { - ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60))); + ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(t.text)); } let key = egui::Id::unique(("reply", &cell)); let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default(); @@ -239,7 +240,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); ui.painter().rect_filled(r, 2.0, col); ui.label(&name); - ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110))); + ui.label(egui::RichText::new(&s.values).small().color(crate::theme::Tokens::get(ui.ctx()).text_dim)); ui.menu_button("Fill…", |ui| { if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") { app.run_or_alert( diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 73c2d6bf9..cdf56bddb 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -182,7 +182,8 @@ fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str { /// Section rectangles (left, centre, right) of a page's header or footer on screen. fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] { let (px, py, w, h) = pg.page_rect(i, j); - let [l, r, t, b, ..] = pg.margins; + let [_, _, t, b, ..] = pg.margins; + let [l, r] = pg.hf_sides; let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) }; let inner = (w - l - r).max(3.0); let third = inner / 3.0; @@ -195,8 +196,8 @@ fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) out } -fn hf_font(geo: &Geo, size_pt: Option, bold: bool) -> FontId { - let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z; +fn hf_font(geo: &Geo, pg: &PageGrid, size_pt: Option, bold: bool) -> FontId { + let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * pg.hf_text_k * geo.z; let px = px.clamp(4.0, 120.0); if bold { theme::ui_bold(px) } else { theme::ui_font(px) } } @@ -235,7 +236,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, if spec.is_empty() { if hovered { let label = if footer { "Add footer" } else { "Add header" }; - p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120)); + p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, &pg, Some(10.0), false), Color32::from_gray(120)); } continue; } @@ -255,7 +256,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, if sec.text.trim().is_empty() { continue; } - let font = hf_font(geo, sec.size, sec.bold); + let font = hf_font(geo, &pg, sec.size, sec.bold); let r = rects[k]; let (anchor, x) = match k { 0 => (Align2::LEFT_TOP, r.left()), @@ -326,8 +327,9 @@ pub fn start_editing(app: &mut SheetApp) { app.grid.hf_start = true; } -/// Turns Different First Page / Different Odd & Even Pages on or off, keeping the edit on the -/// same page (which may now show another header). +/// Turns a Header & Footer option (Different First Page, Different Odd & Even Pages, Scale with +/// Document, Align with Page Margins) on or off, keeping the edit on the same page (which may +/// now show another header). fn set_option(app: &mut SheetApp, key: &str, on: bool) { let Some(e) = app.grid.hf_edit.as_ref() else { return }; let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section }); @@ -446,7 +448,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { let rects = section_rects(geo, pg, e.page, e.footer); let p = ui.painter().with_clip_rect(geo.cells); let t = Tokens::get(ui.ctx()); - let font = hf_font(geo, Some(10.0), false); + let font = hf_font(geo, pg, Some(10.0), false); let mut switch_to = None; for (k, r) in rects.iter().enumerate() { if !r.intersects(geo.cells) { @@ -893,11 +895,10 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { go_to(app, true); } sep(ui); - let (first, odd_even) = app - .session - .active() - .and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet)).map(|s| (s.print.different_first, s.print.different_odd_even))) - .unwrap_or_default(); + let (first, odd_even, scale_doc, align) = + app.session.active().and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet))).map_or((false, false, true, true), |s| { + (s.print.different_first, s.print.different_odd_even, s.print.hf_scale_with_doc, s.print.hf_align_with_margins) + }); ui.vertical(|ui| { let (mut f, mut o) = (first, odd_even); if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() { @@ -907,6 +908,15 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { set_option(app, "differentOddEven", o); } }); + ui.vertical(|ui| { + let (mut sd, mut am) = (scale_doc, align); + if ui.checkbox(&mut sd, tr(app, "Scale with Document")).changed() { + set_option(app, "scaleWithDoc", sd); + } + if ui.checkbox(&mut am, tr(app, "Align with Page Margins")).changed() { + set_option(app, "alignWithMargins", am); + } + }); } fn sep(ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 1b09f40a9..4958ecd59 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -7,8 +7,8 @@ use serde_json::json; use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; -use crate::theme::{self, Tokens}; -use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button}; +use crate::theme::{self, Cat, Tokens}; +use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -58,7 +58,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { 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 }); + ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } 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.toggle_autosave(); } @@ -279,13 +279,32 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if collapsed { return; } - // Ribbon card. - let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 88.0)); + // Ribbon card. The row above it can be wider than a narrow window, so the card is kept + // within the window (with the margin it has on the left), or its right scroll arrow + // would be drawn off screen. + let left = ui.cursor().min.x; + let screen = ui.ctx().content_rect(); + let fits = (screen.right() - left - (left - screen.left()).max(0.0)).max(0.0); + let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width().min(fits), 88.0)); ui.painter().rect_filled(card, 10.0, t.ribbon); ui.painter().rect_stroke(card, 10.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); let inner = card.shrink2(vec2(10.0, 6.0)); ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| { - egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| { + // One scroll state per tab: a tab opened after scrolling another starts at its + // left edge, and a tab keeps its own offset while others are used. + let tab = app.ui.ribbon_tab.clone(); + let mut area = egui::ScrollArea::horizontal() + .id_salt(("ribbon_scroll", tab.as_str())) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + // egui 0.37 keeps `ScrollArea`'s offset private; `scroll_offset` is the supported + // way to move it. An arrow click records the requested offset for the next frame. + // Expire it if this tab stops rendering, rather than replaying it much later. + if let Some((frame, x)) = ui.ctx().data_mut(|d| d.remove_temp::<(u64, f32)>(RibbonOverflow::scroll_target_id(tab.as_str()))) + && ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 1 + { + area = area.horizontal_scroll_offset(x); + } + let scroll = area.show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); match app.ui.ribbon_tab.as_str() { @@ -305,10 +324,91 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { } }); }); + scroll_arrows(ui, &t, tab.as_str(), scroll); }); }); } +/// How much of the active tab's groups the ribbon card shows, recorded each frame for the scroll +/// arrows and for tests. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RibbonOverflow { + /// Width of all the tab's groups. + pub content: f32, + /// Width of the card's content area. + pub visible: f32, + /// How far the groups are scrolled. + pub offset: f32, + /// Height of the tallest group (a column of small buttons) and of the content area. + pub content_height: f32, + pub visible_height: f32, +} + +impl RibbonOverflow { + fn id() -> egui::Id { + egui::Id::unique("ribbon_overflow") + } + + /// Where an arrow click asks the next frame's `ScrollArea` to scroll to. + fn scroll_target_id(tab: &str) -> egui::Id { + egui::Id::unique(("ribbon_scroll_target", tab)) + } + + /// Last frame's overflow. + pub fn get(ctx: &egui::Context) -> RibbonOverflow { + ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default() + } + + /// Whether the groups are wider than the card. + pub fn overflows(&self) -> bool { + self.content > self.visible + 0.5 + } +} + +/// When the tab's groups are wider than the card, arrows at its edges scroll them (horizontal wheel or +/// trackpad input over the card still does too). +fn scroll_arrows(ui: &mut Ui, t: &Tokens, tab: &str, scroll: egui::scroll_area::ScrollAreaOutput<()>) { + let inner = scroll.inner_rect; + let max = (scroll.content_size.x - inner.width()).max(0.0); + let overflow = RibbonOverflow { + content: scroll.content_size.x, + visible: inner.width(), + offset: scroll.state.clamped_offset().x, + content_height: scroll.content_size.y, + visible_height: inner.height(), + }; + ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::id(), overflow)); + if !overflow.overflows() { + return; + } + let step = (inner.width() * 0.6).max(60.0); + let mut target = None; + for (left, show) in [(true, overflow.offset > 0.5), (false, overflow.offset < max - 0.5)] { + if !show { + continue; + } + let r = if left { + Rect::from_min_size(inner.left_top(), vec2(18.0, inner.height())) + } else { + Rect::from_min_size(pos2(inner.right() - 18.0, inner.top()), vec2(18.0, inner.height())) + }; + let resp = ui.interact(r, ui.scope_id().with(("ribbon_scroll_arrow", left)), Sense::click()); + ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.ribbon }); + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if left { Icon::Left } else { Icon::Right }, t.text); + let name = if left { "Scroll ribbon left" } else { "Scroll ribbon right" }; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + if resp.on_hover_text(crate::i18n::current(ui.ctx()).tr(name)).clicked() { + target = Some(if left { overflow.offset - step } else { overflow.offset + step }); + } + } + if let Some(x) = target { + let frame = ui.ctx().cumulative_frame_nr(); + ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::scroll_target_id(tab), (frame, x.clamp(0.0, max)))); + ui.ctx().request_repaint(); + } +} + /// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like /// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes. fn file_menu(app: &mut SheetApp, ui: &mut Ui) { @@ -382,6 +482,23 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { app.run_or_alert(id, params); } +/// How many cells Formulas › Error Checking › Circular References lists. +const CIRCULAR_MENU_LIMIT: usize = 100; + +/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order +/// the calculation found them, as (sheet index, cell, "Sheet!A1"). +fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { + let Some(d) = app.session.active() else { return Vec::new() }; + let mut seen = std::collections::HashSet::new(); + d.calc + .circular + .iter() + .filter(|k| seen.insert(**k)) + .filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", gridcraft_engine::formula::quote_sheet(&sh.name), c.a1())))) + .take(CIRCULAR_MENU_LIMIT) + .collect() +} + /// Keytips are painted, not focusable widgets: the grid retains keyboard focus. fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; @@ -1064,17 +1181,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if s { act(app, "formulas.autoSum", json!({})); } - egui::Popup::menu(&sa).show(|ui| { - for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] { - if ui.button(f).clicked() { - act(app, "formulas.autoSum", json!({"function": f})); - } - } - ui.separator(); - if ui.button("More Functions…").clicked() { - app.open_dialog("insertFunction", json!({})); - } - }); + egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui)); }); let fill = small_button(ui, Icon::FillDown, "", "Fill", true); for sequence in keytip_table::group_extension( @@ -1167,6 +1274,20 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { }); } +/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert +/// Function dialog. +fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) { + for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] { + if ui.button(f).clicked() { + act(app, "formulas.autoSum", json!({"function": f})); + } + } + ui.separator(); + if ui.button("More Functions…").clicked() { + app.open_dialog("insertFunction", json!({})); + } +} + fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let name = st.font.name.clone(); let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5)))); @@ -1390,7 +1511,7 @@ fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) { let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click()); paint_table_swatch(ui, r, &wb, &name); if resp.hovered() { - ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); } if resp.on_hover_text(&name).clicked() { act(app, cmd, json!({"style": name})); @@ -1408,7 +1529,8 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, let lk = gridcraft_engine::tables::look(style); let res = |c: Option| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32); let p = ui.painter(); - p.rect_filled(r, 0.0, Color32::WHITE); + let t = Tokens::get(ui.ctx()); + p.rect_filled(r, 0.0, t.grid_bg); let rows = 5; let rh = r.height() / rows as f32; for i in 0..rows { @@ -1428,14 +1550,15 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, } for k in 0..3 { let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0; - let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(Color32::BLACK) } else { Color32::from_gray(120) }; + let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border }; p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col)); } } - p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); } fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); ui.set_width(520.0); let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; let groups: &[(&str, &[&str])] = &[ @@ -1485,17 +1608,17 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { .resolve(&wb.theme) .filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None) .map(theme::color32) - .unwrap_or(Color32::WHITE); + .unwrap_or(t.grid_bg); ui.painter().rect_filled(r, 0.0, fill); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); if !st.borders.bottom.is_none() { - let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK); + let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); ui.painter().line_segment( [r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)], Stroke::new(st.borders.bottom.style.width(), c), ); } - let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK); + let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); ui.painter().text( r.left_center() + vec2(4.0, 0.0), @@ -1505,7 +1628,7 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { col, ); if resp.hovered() { - ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); } if resp.clicked() { act(app, "home.cellStyle", json!({"name": n})); @@ -1600,6 +1723,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ("100% Stacked Column", "column", "stacked100"), ("Clustered Bar", "bar", ""), ("Stacked Bar", "bar", "stacked"), + ("100% Stacked Bar", "bar", "stacked100"), ], ), ( @@ -1612,6 +1736,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ("Stacked Line", "line", "stacked"), ("Area", "area", ""), ("Stacked Area", "area", "stacked"), + ("100% Stacked Area", "area", "stacked100"), ], ), (Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), @@ -1923,11 +2048,12 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("insertFunction", json!({})); } - let s = big_button(ui, Icon::Sum, "AutoSum", "AutoSum", true); - control_keytip(app, ui, &s, "formulas.autoSum"); + let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum"); + control_keytip(app, ui, &sa, "formulas.autoSum"); if s.clicked() { act(app, "formulas.autoSum", json!({})); } + egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui)); for (label, cat, cid) in [ ("Financial", "Financial", "formulas.financial"), ("Logical", "Logical", "formulas.logical"), @@ -1973,14 +2099,36 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { app.open_dialog("defineName", json!({})); } let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true); - anchor_keytip(app, ui, &use_in, &["formulas.pasteName"]); + anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]); egui::Popup::menu(&use_in).show(|ui| { - let names: Vec = app.session.active().map(|d| d.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); - for n in names { - if ui.button(&n).clicked() { - let cur = app.editor.as_ref().map(|e| e.text.clone()).unwrap_or_else(|| "=".into()); - app.begin_edit(Some(format!("{cur}{n}")), false); + // The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret. + // Listed when the menu opens, and again only after the workbook changes. + let cache = egui::Id::unique("gridcraft.use_in_formula_names"); + let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet)); + let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc>)>(cache)).filter(|(k, _)| *k == key); + let names = match cached { + Some((_, names)) => names, + None => { + let names: std::sync::Arc> = std::sync::Arc::new( + app.session + .active() + .map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default(), + ); + ui.ctx().data_mut(|m| m.insert_temp(cache, (key, names.clone()))); + names } + }; + for n in names.iter() { + if ui.button(n).clicked() { + app.run_or_alert("formulas.useInFormula", json!({"name": n})); + } + } + if !names.is_empty() { + ui.separator(); + } + if ui.button(app.ui.language.tr("Paste Names…")).clicked() { + app.open_dialog("pasteName", json!({})); } }); let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false); @@ -2011,11 +2159,30 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "formulas.showFormulas", json!({})); } - let __k = small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false); - control_keytip(app, ui, &__k, "dialog:errorChecking"); - if __k.clicked() { + let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking"); + control_keytip(app, ui, &ec, "dialog:errorChecking"); + if ec.clicked() { app.open_dialog("errorChecking", json!({})); } + egui::Popup::menu(&ec_arrow).show(|ui| { + let lang = app.ui.language; + if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() { + app.open_dialog("errorChecking", json!({})); + } + let circular = circular_references(app); + ui.add_enabled_ui(!circular.is_empty(), |ui| { + ui.menu_button(lang.tr("Circular References"), |ui| { + egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| { + for (sheet, cell, label) in circular { + if ui.button(label).clicked() { + act(app, "selection.set", json!({"range": cell, "sheet": sheet})); + app.grid.ensure_visible = true; + } + } + }); + }); + }); + }); let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false); control_keytip(app, ui, &__k, "dialog:evaluateFormula"); if __k.clicked() { @@ -2178,6 +2345,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() { act(app, "data.hideDetail", json!({})); } + if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() { + app.open_dialog("outlineSettings", json!({})); + } }); sep(ui); if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() { @@ -2715,11 +2885,6 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { } } -#[allow(dead_code)] -fn unused(_: &Tokens, _: Icon) { - let _ = icons::GREEN; -} - #[cfg(test)] mod title_bar_tests { use super::*; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index c81c4793e..a56219eef 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -55,6 +55,48 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option { Some(json!({"sheet": i, "to": to})) } +/// The New sheet (+) button and the gap before it. +const NEW_SHEET_WIDTH: f32 = 32.0; +/// The handle between the tabs and the horizontal scrollbar. +const SPLIT_WIDTH: f32 = 8.0; +/// The narrowest the scrollbar track gets when the handle is dragged to the right. +const MIN_TRACK: f32 = 100.0; + +/// The share of the tab bar (`bar`: its left edge and width) the tabs and New sheet take with +/// the handle dragged to `x`: New sheet stays shown, and the track ending at `right` keeps +/// [`MIN_TRACK`]. +fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 { + let (left, width) = bar; + if !width.is_finite() || width <= 0.0 || !x.is_finite() { + return 0.6; + } + let min = NEW_SHEET_WIDTH / width; + let max = ((right - SPLIT_WIDTH - MIN_TRACK - left) / width).max(min); + ((x - left) / width).clamp(min, max) +} + +/// The handle at the left edge of the horizontal scrollbar: dragging it gives the sheet tabs more +/// or less of the bar, as in Excel, for the workbook `uid`. +fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) { + app.grid.tab_split = Some(split); + let resp = ui.interact(split, ui.scope_id().with("tab_split"), Sense::drag()); + let name = app.ui.language.tr("Split tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + app.grid.tab_shares.insert(uid, dragged_share(p.x - SPLIT_WIDTH / 2.0, bar, right)); + ui.ctx().request_repaint(); + } + let color = if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }; + for dy in [-4.0, 0.0, 4.0] { + ui.painter().circle_filled(pos2(split.center().x, split.center().y + dy), 1.0, color); + } +} + pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -81,6 +123,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } ui.add_space(8.0); let Some(d) = app.session.active() else { return }; + let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width()); + let uid = d.uid; + // The track ends where the grid does, so it stops under the vertical scrollbar. A share + // dragged in a wider window is held to the same limits as a drag at this width. + let bar_right = app.grid.rect.map_or(bar_left + bar_width, |r| r.right()).min(bar_left + bar_width); + let share = app.grid.tab_shares.get(&uid).map(|&share| dragged_share(bar_left + bar_width * share, (bar_left, bar_width), bar_right)); let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> = d.wb.sheets .iter() @@ -91,7 +139,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the // tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip // that needs less shrinks to its tabs, so New sheet (+) sits right after the last one. - let tabs_width = (ui.available_width() * 0.6).max(100.0); + // After the handle at the scrollbar's left edge is dragged, the tabs and New sheet + // take the share it was dragged to. + let tabs_width = match share { + Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0), + None => (bar_width * 0.6).max(100.0), + }; // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); @@ -116,6 +169,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { egui::ScrollArea::horizontal() .id_salt("worksheet_tabs_horizontal") .auto_shrink([true, false]) + .min_scrolled_width(0.0) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .show(ui, |ui| { if wheel_scroll.abs() > 0.0 { @@ -252,7 +306,13 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right()); - let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); + // The handle sits at the track's left edge. With a dragged share, the room the tabs + // don't fill stays empty before it. + let split_left = + share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left())); + let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom())); + tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); + let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); @@ -263,6 +323,13 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show( ui, |ui| { + // A right-click anywhere on the status bar opens the statistics menu, the text included, so its labels are not + // selectable. The zoom and view controls sense clicks and handle their own. + let background = ui.interact(ui.max_rect(), egui::Id::unique("gridcraft.status_bar"), Sense::click()); + background.context_menu(|ui| status_bar_menu(app, ui)); + let text = |ui: &mut Ui, rich: egui::RichText| { + ui.add(egui::Label::new(rich).selectable(false)); + }; ui.horizontal_centered(|ui| { let mode = match app.session.mode { gridcraft_engine::Mode::Ready => "Ready", @@ -270,25 +337,29 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Edit => "Edit", gridcraft_engine::Mode::Point => "Point", }; - ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); + text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); if let Some(d) = app.session.active() { if d.wb.vba_project.is_some() { ui.add_space(12.0); - ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); + text(ui, egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); } if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual { ui.add_space(12.0); - ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); + text(ui, egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); } if !d.calc.circular.is_empty() { ui.add_space(12.0); - let (si, c) = d.calc.circular[0]; - let name = d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger)); + // Only a loop on the active sheet is named, so the address needs no sheet. + let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet) { + Some((_, c)) => format!("Circular References: {}", c.a1()), + None => "Circular References".to_owned(), + }; + text(ui, egui::RichText::new(label).font(theme::ui_font(12.5)).color(t.danger)); } if app.session.clipboard.is_some() && app.editor.is_none() { ui.add_space(12.0); - ui.label( + text( + ui, egui::RichText::new(app.ui.language.tr("Select destination and press Enter or choose Paste")) .font(theme::ui_font(12.5)) .color(t.text_dim), @@ -338,34 +409,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } } ui.add_space(16.0); - // Average / Count / Sum. - if let Some(d) = app.session.active() - && let Some(sh) = d.wb.active() - && !d.selection.is_single_cell() - { - let st = gridcraft_engine::display::stats(sh, &d.selection.ranges); - let fmt = |v: f64| { - let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string(); - let code = if code == "General" { "#,##0.##########".to_string() } else { code }; - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb) - .text - .trim_end_matches('.') - .to_string() - }; - let mut parts = Vec::new(); - if st.numeric > 0 { - parts.push(format!("Sum: {}", fmt(st.sum))); - } - if st.count > 0 { - parts.push(format!("Count: {}", st.count)); - } - if let Some(a) = st.average { - parts.push(format!("Average: {}", fmt(a))); - } - for p in parts { - ui.label(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)); - ui.add_space(10.0); - } + // The layout runs right to left, so the last statistic is drawn first. + for p in selection_statistics(app).into_iter().rev() { + text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)); + ui.add_space(10.0); } }); }); @@ -373,9 +420,69 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { ); } +/// The statistics the status bar shows for a selection of more than one cell, left to right: Average, Count, +/// Numerical Count, Minimum, Maximum, Sum. Each one shows when it is turned on (`view.statusBar`) +/// and the selection has a value for it. +pub(crate) fn selection_statistics(app: &SheetApp) -> Vec { + let Some(d) = app.session.active() else { return vec![] }; + let Some(sh) = d.wb.active() else { return vec![] }; + if d.selection.is_single_cell() { + return vec![]; + } + let on = app.session.prefs.status_bar; + let st = gridcraft_engine::display::stats(sh, &d.selection.ranges); + let fmt = |v: f64| { + let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string(); + let code = if code == "General" { "#,##0.##########".to_string() } else { code }; + gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb).text.trim_end_matches('.').to_string() + }; + let mut parts = Vec::new(); + if on.average + && let Some(a) = st.average + { + parts.push(format!("Average: {}", fmt(a))); + } + if on.count && st.count > 0 { + parts.push(format!("Count: {}", st.count)); + } + if on.numerical_count && st.count > 0 { + parts.push(format!("Numerical Count: {}", st.numeric)); + } + if on.minimum && st.numeric > 0 { + parts.push(format!("Minimum: {}", fmt(st.min))); + } + if on.maximum && st.numeric > 0 { + parts.push(format!("Maximum: {}", fmt(st.max))); + } + if on.sum && st.numeric > 0 { + parts.push(format!("Sum: {}", fmt(st.sum))); + } + parts +} + +/// The status bar's context menu: a check item for each statistic, each one running `view.statusBar`. +fn status_bar_menu(app: &mut SheetApp, ui: &mut Ui) { + ui.set_min_width(180.0); + let st = app.session.prefs.status_bar; + for (key, label, mut on) in [ + ("average", "Average", st.average), + ("count", "Count", st.count), + ("numericalCount", "Numerical Count", st.numerical_count), + ("minimum", "Minimum", st.minimum), + ("maximum", "Maximum", st.maximum), + ("sum", "Sum", st.sum), + ] { + if ui.checkbox(&mut on, app.ui.language.tr(label)).changed() { + app.run_or_alert("view.statusBar", json!({ key: on })); + } + } +} + #[cfg(test)] mod tests { - use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop}; + use super::{ + MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop, + }; use crate::SheetApp; use egui::Modifiers; use gridcraft_engine::model::{Sheet, Visibility}; @@ -398,6 +505,30 @@ mod tests { app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect() } + #[test] + fn status_bar_shows_the_statistics_that_are_turned_on_in_order() { + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "x"), ("A4", "9")] { + app.run("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + app.run("selection.set", json!({"range": "A1:A5"})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Sum: 12"]); + app.run("view.statusBar", json!({"numericalCount": true, "minimum": true, "maximum": true})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Numerical Count: 3", "Minimum: -1", "Maximum: 9", "Sum: 12"]); + app.run("view.statusBar", json!({"average": false, "count": false, "sum": false})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Numerical Count: 3", "Minimum: -1", "Maximum: 9"]); + // Text only: Count and Numerical Count, nothing that needs a number. + app.run("view.statusBar", json!({"average": true, "count": true, "sum": true})).unwrap(); + app.run("cell.set", json!({"cell": "B1", "input": "a"})).unwrap(); + app.run("cell.set", json!({"cell": "B2", "input": "b"})).unwrap(); + app.run("selection.set", json!({"range": "B1:B2"})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Count: 2", "Numerical Count: 0"]); + app.run("selection.set", json!({"range": "A1"})).unwrap(); + assert!(super::selection_statistics(&app).is_empty(), "one cell shows no statistics"); + } + #[test] fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() { // egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS. @@ -461,4 +592,15 @@ mod tests { let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap(); assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet"); } + + #[test] + fn a_dragged_share_keeps_new_sheet_and_the_scrollbar_shown() { + // A 1,000 px bar from x = 100 whose track ends at x = 1,100. + let bar = (100.0, 1000.0); + assert_eq!(dragged_share(600.0, bar, 1100.0), 0.5); + assert_eq!(dragged_share(0.0, bar, 1100.0), NEW_SHEET_WIDTH / 1000.0, "New sheet stays shown"); + assert_eq!(dragged_share(1100.0, bar, 1100.0), (1000.0 - SPLIT_WIDTH - MIN_TRACK) / 1000.0, "the track keeps its minimum"); + assert_eq!(dragged_share(f32::NAN, bar, 1100.0), 0.6); + assert_eq!(dragged_share(600.0, (100.0, 0.0), 1100.0), 0.6, "an empty bar has the default share"); + } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ab4495bfe..c6a55b431 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -8,6 +8,11 @@ //! families never enter it, and a web build keeps only its explicit allowlist). Native builds opt //! into craft-fonts' OFL LXGW Chinese face, so a host without a Han system face still draws Chinese //! interface text; no font file is stored in this repo. +//! +//! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours +//! drawn on the sheet keep their light value in both themes, because the sheet stays white. The +//! foreground/background pairs are listed in `contrast_tests`, which holds them to WCAG 2 (4.5:1 +//! for text, 3:1 for glyphs) in both themes: a new token goes there too. use std::sync::Arc; @@ -77,8 +82,74 @@ pub struct Tokens { pub notice_warn_border: Color32, pub notice_info_bg: Color32, pub notice_info_border: Color32, + /// Text and glyphs drawn on an `accent` fill (primary buttons, switched-on toggles). + pub on_accent: Color32, + /// The category palette, indexed by [`Cat`]: the coloured parts of icons, gallery highlights. + pub cat: [Color32; 8], + /// The page colour inside icons (documents, cells, clipboards). + pub paper: Color32, + /// Reference colours for formulas edited in the window-coloured formula bar. + pub ref_colors: [Color32; 8], + /// Reference colours on the sheet (outlines and the in-cell editor). The sheet stays white. + pub sheet_ref_colors: [Color32; 8], + /// Code colours in the code editor (and the window-coloured fields it sits in). + pub syntax: Syntax, + /// The message box warning disc and its glyph. + pub warning: Color32, + pub on_warning: Color32, + /// Controls drawn on the sheet (filter and validation buttons, object handles). They follow + /// the sheet, so they are light in both themes. + pub sheet_control_bg: Color32, + pub sheet_control_border: Color32, + pub sheet_control_text: Color32, + /// A placeholder fill on the sheet (a picture that has not loaded). + pub sheet_track: Color32, } +/// The eight category colours, in the order of [`Tokens::cat`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Cat { + Blue, + Green, + Orange, + Red, + Purple, + Teal, + Amber, + Slate, +} + +impl Cat { + pub const ALL: [Cat; 8] = [Cat::Blue, Cat::Green, Cat::Orange, Cat::Red, Cat::Purple, Cat::Teal, Cat::Amber, Cat::Slate]; +} + +const fn rgb(hex: u32) -> Color32 { + Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) +} + +/// Reference colours on the white sheet, the same in both themes (the sheet does not follow the +/// theme). The light theme's formula bar is white too, so it uses them as well. +const SHEET_REF_COLORS: [Color32; 8] = + [rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)]; +/// Code colours for one background; plain text uses the surface's own text colour. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Syntax { + pub keyword: Color32, + pub string: Color32, + pub number: Color32, + pub comment: Color32, + pub builtin: Color32, +} + +const LIGHT_SYNTAX: Syntax = + Syntax { keyword: rgb(0x0B57B3), string: rgb(0xA3361B), number: rgb(0x157A5A), comment: rgb(0x6B7280), builtin: rgb(0x7B3FB5) }; +const DARK_SYNTAX: Syntax = + Syntax { keyword: rgb(0x6FAEFF), string: rgb(0xF09A7A), number: rgb(0x7FD1A8), comment: rgb(0x9AA3AE), builtin: rgb(0xC69BF2) }; +const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3); +const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A); +const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F); +const SHEET_TRACK: Color32 = rgb(0xD6D6D6); + impl Tokens { pub fn light() -> Tokens { Tokens { @@ -102,7 +173,7 @@ impl Tokens { header_text: Color32::from_rgb(0x42, 0x42, 0x42), header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6), header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1), - header_sel_text: Color32::from_rgb(0x10, 0x7C, 0x41), + header_sel_text: Color32::from_rgb(0x0B, 0x5E, 0x31), header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3), sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C), sel_border: Color32::from_rgb(0x10, 0x7C, 0x41), @@ -122,6 +193,18 @@ impl Tokens { notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC), + on_accent: Color32::WHITE, + cat: [rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)], + paper: Color32::WHITE, + ref_colors: SHEET_REF_COLORS, + sheet_ref_colors: SHEET_REF_COLORS, + syntax: LIGHT_SYNTAX, + warning: rgb(0xF2C32E), + on_warning: rgb(0x1F1F1F), + sheet_control_bg: SHEET_CONTROL_BG, + sheet_control_border: SHEET_CONTROL_BORDER, + sheet_control_text: SHEET_CONTROL_TEXT, + sheet_track: SHEET_TRACK, } } @@ -147,7 +230,7 @@ impl Tokens { header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC), header_line: Color32::from_rgb(0x45, 0x45, 0x45), header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45), - header_sel_text: Color32::from_rgb(0x5C, 0xC5, 0x87), + header_sel_text: Color32::from_rgb(0x6F, 0xD3, 0x9A), header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E), sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24), sel_border: Color32::from_rgb(0x10, 0x7C, 0x41), @@ -159,7 +242,7 @@ impl Tokens { cell_text: Color32::BLACK, menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), shadow: Color32::from_black_alpha(90), - danger: Color32::from_rgb(0xF1, 0x70, 0x5F), + danger: Color32::from_rgb(0xFF, 0x95, 0x85), error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B), warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05), warning_ink: Color32::BLACK, @@ -167,12 +250,50 @@ impl Tokens { notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72), + on_accent: rgb(0x0F2A1A), + cat: [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)], + paper: rgb(0x454545), + ref_colors: [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)], + sheet_ref_colors: SHEET_REF_COLORS, + syntax: DARK_SYNTAX, + warning: rgb(0xF2C32E), + on_warning: rgb(0x1F1F1F), + sheet_control_bg: SHEET_CONTROL_BG, + sheet_control_border: SHEET_CONTROL_BORDER, + sheet_control_text: SHEET_CONTROL_TEXT, + sheet_track: SHEET_TRACK, } } pub fn get(ctx: &egui::Context) -> Tokens { if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() } } + + /// One colour of the category palette. + pub fn cat(&self, c: Cat) -> Color32 { + self.cat.get(c as usize).copied().unwrap_or(self.text) + } + + /// The reference colour for a formula's `index`-th reference, on the bar or on the sheet. + pub fn ref_color(&self, index: usize, on_sheet: bool) -> Color32 { + let set = if on_sheet { &self.sheet_ref_colors } else { &self.ref_colors }; + set.get(index % set.len()).copied().unwrap_or(self.text) + } +} + +/// WCAG 2 relative luminance of an opaque colour. +pub fn luminance(c: Color32) -> f32 { + let lin = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) +} + +/// WCAG 2 contrast ratio between two opaque colours (1 to 21). +pub fn contrast(a: Color32, b: Color32) -> f32 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } pub const UI: &str = "ui"; @@ -1043,6 +1164,104 @@ mod font_binding_tests { } } +#[cfg(test)] +mod contrast_tests { + use super::*; + + /// Body text needs 4.5:1 (WCAG AA); glyphs, icons and other graphics need 3:1. + const TEXT: f32 = 4.5; + const GLYPH: f32 = 3.0; + + /// Every foreground/background pair the chrome draws: ribbon, popups, dialogs, panes, formula + /// bar, status bar, plus the colours that sit on the white sheet. + fn pairs(t: &Tokens) -> Vec<(String, Color32, Color32, f32)> { + let mut v: Vec<(String, Color32, Color32, f32)> = Vec::new(); + let mut add = |name: &str, fg: Color32, bg: Color32, min: f32| v.push((name.to_string(), fg, bg, min)); + let surfaces = [ + ("window", t.window), + ("ribbon", t.ribbon), + ("menu", t.menu_bg), + ("input", t.input_bg), + ("status bar", t.status_bar), + ("tab bar", t.tab_bar), + ("active tab", t.tab_active), + ("hover", t.hover), + ]; + for (name, bg) in surfaces { + add(&format!("text on {name}"), t.text, bg, TEXT); + add(&format!("dim text on {name}"), t.text_dim, bg, TEXT); + add(&format!("accent glyph on {name}"), t.accent, bg, GLYPH); + add(&format!("danger on {name}"), t.danger, bg, TEXT); + } + add("text on pressed", t.text, t.pressed, TEXT); + add("accent text (File, contextual tabs) on window", t.accent, t.window, TEXT); + for (name, bg) in [("ribbon", t.ribbon), ("menu", t.menu_bg), ("hover", t.hover), ("pressed", t.pressed)] { + for c in Cat::ALL { + add(&format!("{c:?} icon colour on {name}"), t.cat(c), bg, GLYPH); + } + } + add("icon stroke on paper", t.text, t.paper, GLYPH); + add("on_accent on accent", t.on_accent, t.accent, TEXT); + add("warning glyph", t.on_warning, t.warning, TEXT); + add("notice text (warning)", t.text, t.notice_warn_bg, TEXT); + add("notice text (info)", t.text, t.notice_info_bg, TEXT); + add("header text", t.header_text, t.header_bg, TEXT); + add("selected header text", t.header_sel_text, t.header_sel_bg, TEXT); + for (i, c) in t.ref_colors.iter().enumerate() { + add(&format!("formula bar reference {i}"), *c, t.input_bg, TEXT); + } + let s = t.syntax; + for (name, c) in [("keyword", s.keyword), ("string", s.string), ("number", s.number), ("comment", s.comment), ("builtin", s.builtin)] { + add(&format!("code {name} on input"), c, t.input_bg, TEXT); + } + // On the sheet. + add("cell text", t.cell_text, t.grid_bg, TEXT); + for (i, c) in t.sheet_ref_colors.iter().enumerate() { + add(&format!("sheet reference {i}"), *c, t.grid_bg, TEXT); + } + add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH); + add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT); + add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); + v + } + + #[test] + fn token_pairs_meet_wcag_contrast_in_both_themes() { + let mut failures = Vec::new(); + for t in [Tokens::light(), Tokens::dark()] { + for (name, fg, bg, min) in pairs(&t) { + let ratio = contrast(fg, bg); + if ratio < min { + failures.push(format!("{} theme: {name} is {ratio:.2}:1, needs {min}:1", if t.dark { "dark" } else { "light" })); + } + } + } + assert!(failures.is_empty(), "contrast failures:\n{}", failures.join("\n")); + } + + #[test] + fn contrast_matches_wcag_reference_values() { + assert!((contrast(Color32::BLACK, Color32::WHITE) - 21.0).abs() < 0.01); + assert!((contrast(Color32::WHITE, Color32::WHITE) - 1.0).abs() < 0.001); + // #767676 on white is the classic 4.54:1 grey. + assert!((contrast(rgb(0x767676), Color32::WHITE) - 4.54).abs() < 0.01); + } + + #[test] + fn sheet_tokens_follow_the_sheet_not_the_theme() { + let (l, d) = (Tokens::light(), Tokens::dark()); + assert_eq!(l.grid_bg, d.grid_bg); + assert_eq!(l.sheet_ref_colors, d.sheet_ref_colors); + assert_eq!( + (l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track), + (d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track) + ); + for c in Cat::ALL { + assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own"); + } + } +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index be5da0e0f..6713b5700 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -4,9 +4,10 @@ use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::SheetApp; +pub use crate::code_editor::{Lang, code_editor}; use crate::i18n; use crate::icons::{self, Icon}; -use crate::theme::{self, Tokens}; +use crate::theme::{self, Cat, Tokens}; /// Translates a label or tooltip for the language of the frame being drawn (English if untranslated). /// Ribbon chrome all flows through these widgets, so translating here localises it in one place. @@ -98,13 +99,8 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { /// Large ribbon button: 32px icon over a one- or two-line label. pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); - let font = theme::ui_font(11.5); let label = tr(ui, label); - let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect(); - let arrow_size = 10.0; - let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 }; - let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max); - let w = (text_w + 12.0).max(44.0); + let (lines, w) = big_label(ui, label, dropdown); let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click()); if resp.is_pointer_button_down_on() { ui.painter().rect_filled(rect, 5.0, t.pressed); @@ -112,6 +108,59 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ui.painter().rect_filled(rect, 5.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); + paint_big_label(ui, rect, &lines, dropdown); + resp.on_hover_text(tip_text(ui, tip)) +} + +/// Large split button (Formulas › AutoSum): the icon runs the default action, the label and its +/// arrow open the menu. Returns the (main, arrow) responses; their accessible names are the label +/// and "