M0.37: reconcile GridCraft main and qualify the final UI port

This commit is contained in:
skylartaylor
2026-10-25 07:55:28 +01:00
94 changed files with 5766 additions and 507 deletions
+10
View File
@@ -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 |
+4
View File
@@ -99,3 +99,7 @@ real_name = "Tyrel Haveman"
[people.helenkwok]
real_name = "Helen Kwok"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+60 -8
View File
@@ -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<usize> {
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
}
+140 -2
View File
@@ -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<Key>,
/// 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<usize> {
(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<RangeRef> {
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<Key> {
if circ.is_empty() {
return Vec::new();
}
let index: HashMap<Key, usize> = 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<Vec<usize>> = circ
.iter()
.map(|k| {
deps.clear();
self.graph.dependents(k.0, k.1, &mut deps);
let mut e: Vec<usize> = 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<Key> =
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<usize> = out.iter().filter_map(|k| index.get(k)).filter_map(|&i| comp.get(i).copied()).collect();
let mut order: Vec<usize> = (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<Key> = dirty.iter().copied().collect();
self.circular.retain(|k| !evaluated.contains(k));
}
self.circular.extend(cycles);
// Write back.
let mut spill_changes: Vec<Key> = 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));
}
}
}
+120
View File
@@ -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();
+30 -14
View File
@@ -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<bool> = 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<f64>)> = 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<f64>, Vec<f64>)> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = 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),
};
+42 -2
View File
@@ -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", "", ""];
+165 -8
View File
@@ -106,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {
}
}
// 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<Json> {
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<outline::OutlineGroup> {
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<Json> {
};
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<outline::OutlineGroup> = 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<Json> {
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<Json> {
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<Json> {
})
}
/// 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<Json> {
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::OutlineGroup> = 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<u32, (bool, u8)> = 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<Json> {
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<Json> {
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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), [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();
+260 -16
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
"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<Json> {
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<Json> {
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<usize> = None;
let mut areas: Vec<RangeRef> = 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::<Vec<_>>().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::<Vec<_>>().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<Item = &str> {
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<RangeRef>, 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<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = 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<String> {
let own: std::collections::HashSet<String> = wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| n.name.to_ascii_lowercase()).collect();
let mut out: Vec<String> = 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<Json> {
@@ -1462,6 +1589,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"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<Json> {
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<_>>(), 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.
+1 -1
View File
@@ -1270,7 +1270,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
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()
}
+159 -3
View File
@@ -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<CommandSpec> {
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<Json> {
})
}
/// 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<String> = 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<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = 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<Json> {
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<Json> {
}
}
}
let circ: Vec<String> =
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<String> = 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!([]));
}
}
+2 -1
View File
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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,
+113 -3
View File
@@ -184,10 +184,19 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Json> {
.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<Json> {
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<Json> {
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<Json> {
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<Json> {
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::<Vec<_>>(),
"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"));
}
}
+56 -2
View File
@@ -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();
+60 -1
View File
@@ -44,6 +44,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
"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<Json> {
})
}
fn status_bar(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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<Json> {
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)) = &section {
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<Json> {
"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);
}
}
+145 -6
View File
@@ -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<Vec<String>> {
let short_days: Vec<String> = 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<Cell>)| 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<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
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<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = 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<bool> {
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<f64> {
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<i64> {
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<f64> {
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 {
+3 -3
View File
@@ -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");
+29
View File
@@ -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<String>,
}
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<String>,
/// 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<String>),
/// 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),
}
+45 -9
View File
@@ -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<u32, LineInfo>) -> 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<u32, LineInfo>) -> Vec<OutlineGroup> {
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<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
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<u32, LineInfo>) -> Vec<OutlineGroup> {
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));
}
}
+38 -2
View File
@@ -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);
+39 -17
View File
@@ -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<Option<(Source, Vec<usize>)>, 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<HashSet<(u32, u32)>, 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<u32>, Vec<u32>)>,
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
cells: Option<HashSet<(usize, usize)>>,
}
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
@@ -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());
}
+116
View File
@@ -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::<Vec<_>>();
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
// 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::<Vec<_>>();
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",
@@ -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<ChartKind> {
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]);
}
+1
View File
@@ -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;
+14 -12
View File
@@ -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)]
+5
View File
@@ -410,6 +410,7 @@ pub enum ChartKind {
Doughnut,
Area,
AreaStacked,
AreaStacked100,
Scatter,
ScatterLines,
Bubble,
@@ -1044,6 +1045,9 @@ pub struct PivotTable {
pub values: Vec<PivotValue>,
pub filters: Vec<PivotFilter>,
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,
+9
View File
@@ -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<CondFormat>,
@@ -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,
+6 -1
View File
@@ -130,7 +130,12 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
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,
);
}
}
+12 -9
View File
@@ -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;
}
+936
View File
@@ -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<usize>, Token)> {
match lang {
Lang::Python => python(text),
Lang::Latex => latex(text),
Lang::Plain => Vec::new(),
}
}
fn python(text: &str) -> Vec<(Range<usize>, 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<usize>, 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<egui::Event>,
}
/// 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<egui::Event>, 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<egui::Event>,
}
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<egui::Event> {
let pass = ui.ctx().cumulative_pass_nr();
ui.ctx()
.data_mut(|d| d.remove_temp::<Held>(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<egui::Event>) {
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>(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");
}
}
+1
View File
@@ -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}"),
+139 -16
View File
@@ -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<String> {
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<String> = 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<String> =
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<String> {
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<String> =
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<String> =
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:");
+47 -13
View File
@@ -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<String>,
/// 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<String>,
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<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
+219 -54
View File
@@ -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<Rect>,
}
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<Rect>,
}
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::Galley>,
) -> 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");
}
}
}
}
+84 -16
View File
@@ -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<std::collections::HashSet<(u32, u32)>>)>,
pub header_menu: Option<(Pos2, bool)>,
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
pub paper: Option<crate::paper_view::PaperCache>,
@@ -176,6 +181,11 @@ pub struct GridState {
pub geo: Option<Geo>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
pub tab_split: Option<Rect>,
/// 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<u64, f32>,
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
pub wheel_zoom: Option<WheelZoom>,
/// 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<TableHeadin
}
fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) {
p.rect_filled(r, 2.0, Color32::from_gray(250));
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside);
crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70));
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(1.5),
if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron },
t.sheet_control_text,
);
}
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
@@ -1789,7 +1805,7 @@ pub(crate) fn paint_selection(
continue;
}
let rr = geo.range_rect(sh, r);
let col = REF_COLORS[idx];
let col = Tokens::get(p.ctx()).ref_color(idx, true);
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside);
for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] {
@@ -3157,6 +3173,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
let size = st.font.size * 96.0 / 72.0 * geo.z;
let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
// Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar
// is the one that stops at five lines).
let shown = lines;
// Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does.
// Otherwise it widens to the text's one-line width.
let wrap = st.align.wrap;
@@ -3164,7 +3183,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
// The field wraps at the width it is given (the editor's less its 2 px inset a side): its
// frame is `Frame::NONE`, so no text margin is taken off.
let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0));
(rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height()))
(rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height()))
} else {
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
// Excel keeps the cell's height for one line and only grows for extra lines.
@@ -3201,7 +3220,8 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
// theme, on the white paper): pick black or white against the fill, like Excel.
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0);
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect));
}
/// Test the popup itself, not whether the pointer is over any part of the app.
@@ -3370,6 +3390,12 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
if let Some(item) = insert_copied {
items.insert(6, item);
}
if show_details_offered(app)
&& let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending")
{
items.insert(i + 1, ("-", ""));
items.insert(i + 2, ("Show Details", "pivot.showDetails"));
}
let lang = crate::i18n::current(ui.ctx());
for (label, id) in items {
if label == "-" {
@@ -3426,10 +3452,48 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
}
}
/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell
/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of
/// date, where running the command says so. Labels and headers of a current report don't offer it,
/// and no cell does while the PivotTable's Enable show details option is off.
fn show_details_offered(app: &mut SheetApp) -> 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()
+3
View File
@@ -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í",
+3
View File
@@ -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",
+4
View File
@@ -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" => "Ακτινωτό",
+3
View File
@@ -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",
+3
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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", "レーダー"),
+3
View File
@@ -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.
+3
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+3
View File
@@ -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.
+3
View File
@@ -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",
+3
View File
@@ -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",
+4
View File
@@ -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",
+3
View File
@@ -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.
+3
View File
@@ -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.
+154 -121
View File
@@ -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<Icon> {
}
}
/// 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 => {
+3
View File
@@ -103,6 +103,8 @@ pub fn lookup(key: Key, physical: Option<Key>, 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<Key>, 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!({})),
@@ -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)
+3 -4
View File
@@ -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",
+6
View File
@@ -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);
+57 -3
View File
@@ -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<u32, LineInfo>) -> 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();
+7 -6
View File
@@ -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(
+23 -13
View File
@@ -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<f32>, 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<f32>, 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) {
+207 -42
View File
@@ -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<gridcraft_engine::model::Color>| 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<String> = 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<Vec<String>>)>(cache)).filter(|(k, _)| *k == key);
let names = match cached {
Some((_, names)) => names,
None => {
let names: std::sync::Arc<Vec<String>> = 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::*;
+180 -38
View File
@@ -55,6 +55,48 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option<serde_json::Value> {
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<String> {
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");
}
}
+222 -3
View File
@@ -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::*;
+96 -14
View File
@@ -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 "<label> options".
pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
let t = Tokens::get(ui.ctx());
let label = tr(ui, label);
let (lines, w) = big_label(ui, label, true);
let (rect, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
let split = rect.top() + 38.0;
let main_rect = Rect::from_min_max(rect.min, pos2(rect.right(), split));
let arrow_rect = Rect::from_min_max(pos2(rect.left(), split), rect.max);
let main = ui.interact(main_rect, ui.scope_id().with((label, "big-split-main")), Sense::click());
let arrow = ui.interact(arrow_rect, ui.scope_id().with((label, "big-split-arrow")), Sense::click());
if main.hovered() || arrow.hovered() {
ui.painter().rect_filled(rect, 5.0, t.hover);
ui.painter().line_segment([pos2(rect.left() + 4.0, split), pos2(rect.right() - 4.0, split)], Stroke::new(1.0, t.separator));
}
for (part, r) in [(&main, main_rect), (&arrow, arrow_rect)] {
if part.is_pointer_button_down_on() {
ui.painter().rect_filled(r, 5.0, t.pressed);
}
}
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, true);
let name = label.replace('\n', " ");
let enabled = ui.is_enabled();
main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name));
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options")));
let tip = tip_text(ui, tip);
(main.on_hover_text(tip.clone()), arrow.on_hover_text(tip))
}
/// Size of the dropdown arrow beside a big button's last label line.
const BIG_ARROW: f32 = 10.0;
/// A big button's label lines, laid out, and the width the button needs for them.
fn big_label(ui: &Ui, label: &str, dropdown: bool) -> (Vec<std::sync::Arc<egui::Galley>>, f32) {
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(11.5);
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
let arrow_space = if dropdown { 4.0 + BIG_ARROW } 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);
(lines, (text_w + 12.0).max(44.0))
}
/// Paints a big button's label lines in `rect`, with the dropdown arrow beside the last one.
fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>], dropdown: bool) {
let t = Tokens::get(ui.ctx());
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
for (i, line) in lines.iter().enumerate() {
let has_arrow = dropdown && i + 1 == lines.len();
let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 };
@@ -121,13 +170,12 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
if has_arrow {
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)),
Rect::from_center_size(pos2(left + line_w - BIG_ARROW / 2.0, y + 1.0), vec2(BIG_ARROW, BIG_ARROW)),
Icon::Chevron,
t.text_dim,
);
}
}
resp.on_hover_text(tip_text(ui, tip))
}
/// Small ribbon button: 16px icon with optional label to the right.
@@ -179,6 +227,38 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option<Color32>, tip: &str)
(m.on_hover_text(tip_text(ui, tip)).clicked(), a)
}
/// A small ribbon split button with a label (Error Checking): the icon and label run the default
/// action, the arrow at the right opens a menu. Returns (main response, arrow response).
pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(12.5);
let label = tr(ui, label);
let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0;
let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover());
let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height()));
let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max);
let m = ui.interact(main, ui.scope_id().with((tip, "main")), Sense::click());
let a = ui.interact(arrow, ui.scope_id().with((tip, "arrow")), Sense::click());
let name = label.replace('\n', " ");
let enabled = ui.is_enabled();
m.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name));
a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options")));
if m.hovered() || a.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
for (part, r) in [(&m, main), (&a, arrow)] {
if part.is_pointer_button_down_on() {
ui.painter().rect_filled(r, 4.0, t.pressed);
}
}
ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator));
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text);
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text);
icons::paint(ui.painter(), Rect::from_center_size(pos2(arrow.center().x, rect.center().y + 1.0), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim);
let tip = tip_text(ui, tip);
(m.on_hover_text(tip.clone()), a.on_hover_text(tip))
}
/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is
/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit).
pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f32>) -> Option<String> {
@@ -259,6 +339,7 @@ pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &s
}
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
let t = Tokens::get(ui.ctx());
let mut picked = None;
const ROWS: usize = 7; // 6 tint rows + 1 standard row
const COLS: usize = 10;
@@ -352,9 +433,9 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, col32);
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
if resp.hovered() || cur == Some((row, col)) {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
}
if resp.clicked() {
picked = Some(theme_hex(base, *tint));
@@ -374,7 +455,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, c);
if resp.hovered() || cur == Some((ROWS - 1, col)) {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
}
if resp.clicked() {
picked = Some(hex.to_string());
@@ -461,8 +542,9 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) {
ui.set_width(380.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover());
ui.painter().circle_filled(r.center(), 16.0, Color32::from_rgb(0xF2, 0xC3, 0x2E));
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), Color32::WHITE);
let t = Tokens::get(ui.ctx());
ui.painter().circle_filled(r.center(), 16.0, t.warning);
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), t.on_warning);
ui.vertical(|ui| {
ui.label(egui::RichText::new(title).strong());
ui.add(egui::Label::new(msg).wrap());
@@ -0,0 +1,160 @@
//! Formulas › Error Checking is a split button, as in Excel: the button opens the Error Checking
//! dialog, and its arrow opens a menu whose Circular References submenu lists a cell on each
//! circular reference and selects the one picked, on its own sheet.
use egui::{Event, Shape, pos2, vec2};
use egui_kittest::kittest::{NodeT as _, Queryable as _};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
/// The submenu item's accessible name. egui 0.37 draws the submenu arrow as decoration and no
/// longer folds it into the label, so screen readers (and kittest) see the purpose without it.
const CIRCULAR: &str = "Circular References";
fn harness(loops: bool) -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Q1 Data"})).unwrap();
if loops {
s.execute("cell.set", json!({"cell": "B3", "input": "=B4"})).unwrap();
s.execute("cell.set", json!({"cell": "B4", "input": "=B3"})).unwrap();
}
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
if loops {
s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap();
}
s.execute("selection.set", json!({"range": "H20"})).unwrap();
let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(s),
);
h.run_steps(4);
h.state_mut().ui.ribbon_tab = "Formulas".into();
h.run_steps(4);
h
}
/// The painted "Error Checking" ribbon label: its left edge, right edge and vertical center.
fn label(h: &Harness) -> (f32, f32, f32) {
h.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == "Error Checking" => {
Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0))
}
_ => None,
})
.expect("the Formulas tab shows Error Checking")
}
fn click(h: &mut Harness, at: egui::Pos2) {
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(2);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
}
h.run_steps(2);
}
fn open_menu(h: &mut Harness) {
let (_, right, y) = label(h);
// The arrow sits right of the label, past the 6 px gap after the text.
click(h, pos2(right + 13.0, y));
}
fn hover(h: &mut Harness, label: &str) {
let at = h.get_by_label(label).rect().center();
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(6);
}
#[test]
fn error_checking_button_opens_the_dialog_and_its_arrow_opens_the_menu() {
let mut h = harness(false);
let (left, _, y) = label(&h);
click(&mut h, pos2(left + 4.0, y));
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the button opens Error Checking");
h.state_mut().dialog = None;
h.run_steps(2);
open_menu(&mut h);
assert!(h.state().dialog.is_none(), "the arrow opens a menu, not the dialog");
h.get_by_label("Error Checking…").click();
h.run_steps(4);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the menu's first item opens Error Checking");
}
#[test]
fn circular_references_is_unavailable_without_a_circular_reference() {
let mut h = harness(false);
open_menu(&mut h);
assert!(h.get_by_label(CIRCULAR).accesskit_node().is_disabled());
}
#[test]
fn circular_references_lists_the_loops_and_selects_the_cell_picked() {
let mut h = harness(true);
open_menu(&mut h);
assert!(!h.get_by_label(CIRCULAR).accesskit_node().is_disabled());
hover(&mut h, CIRCULAR);
assert!(h.query_by_label("Sheet1!C2").is_some(), "the loop on the active sheet is listed");
// A sheet name with a space is quoted, as in a formula.
let other = ["'Q1 Data'!B3", "'Q1 Data'!B4"].into_iter().find(|l| h.query_by_label(l).is_some()).expect("the loop on Q1 Data is listed");
h.get_by_label(other).click();
h.run_steps(4);
let app = h.state();
let d = app.session.active().unwrap();
assert_eq!(d.wb.active_sheet, 1, "picking a cell on Q1 Data goes to Q1 Data");
assert_eq!(format!("'Q1 Data'!{}", d.selection.active.a1()), other);
}
#[test]
fn circular_references_scrolls_to_the_cell_picked() {
let mut h = harness(false);
h.state_mut().run("cell.set", json!({"cell": "C500", "input": "=C500+1"})).unwrap();
h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap();
h.run_steps(4);
assert_eq!(h.state().view().scroll.y, 0.0, "setup: the view is at the top of the sheet");
open_menu(&mut h);
hover(&mut h, CIRCULAR);
h.get_by_label("Sheet1!C500").click();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500");
assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked");
}
#[test]
fn circular_references_scrolls_a_list_taller_than_the_window() {
let mut h = harness(false);
for row in 1..=100 {
h.state_mut().run("cell.set", json!({"cell": format!("A{row}"), "input": format!("=A{row}+1")})).unwrap();
}
h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap();
h.run_steps(4);
open_menu(&mut h);
hover(&mut h, CIRCULAR);
let window = h.ctx.content_rect();
assert!(h.get_by_label("Sheet1!A100").rect().bottom() > window.bottom(), "setup: the last cell starts below the window");
hover(&mut h, "Sheet1!A1");
for _ in 0..20 {
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
source: egui::MouseWheelSource::Unknown,
delta: vec2(0.0, -10.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(4);
}
let last = h.get_by_label("Sheet1!A100").rect();
assert!(last.bottom() <= window.bottom(), "the wheel brings the last cell into the window: {last:?} in {window:?}");
click(&mut h, last.center());
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100");
}
@@ -1,5 +1,6 @@
//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…).
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…, and from
//! a date Fill Days, Fill Weekdays, Fill Months and Fill Years).
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable;
@@ -203,6 +204,34 @@ fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() {
assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]);
}
#[test]
fn a_date_adds_fill_days_weekdays_months_and_years() {
let mut h = harness(|s| {
// Friday 1/30/2026 is serial 46052.
s.execute("cell.set", json!({"cell": "B1", "input": "1/30/2026"})).unwrap();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
});
drag_handle(&mut h, "B1", "B3", PointerButton::Secondary);
for label in ["Fill Days", "Fill Months", "Fill Years"] {
assert!(h.query_by_label(label).is_some(), "{label}");
}
h.get_by_label("Fill Weekdays").click();
h.run_steps(3);
assert_eq!(values(&h, &["B2", "B3"]), [n(46055.0), n(46056.0)], "Monday 2/2 and Tuesday 2/3");
assert!(h.state().grid.fill_menu.is_none());
}
#[test]
fn a_number_gets_no_date_fills() {
let mut h = harness(one_two);
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
assert!(h.query_by_label("Fill Series").is_some());
for label in ["Fill Days", "Fill Weekdays", "Fill Months", "Fill Years"] {
assert!(h.query_by_label(label).is_none(), "{label}");
}
}
#[test]
fn escape_mid_right_drag_cancels_it() {
let mut h = harness(|s| {
+29 -1
View File
@@ -1,5 +1,6 @@
//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells,
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403).
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403), and from a date as Fill
//! Days, Fill Weekdays, Fill Months or Fill Years.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable;
@@ -139,6 +140,33 @@ fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() {
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]);
}
#[test]
fn fill_months_redoes_a_date_drag_by_months() {
let mut h = harness(|s| {
// 1/31/2026 is serial 46053.
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
});
drag_handle(&mut h, "B1", "B3", PointerButton::Primary);
assert_eq!(values(&h, &["B2", "B3"]), [n(46054.0), n(46055.0)], "a plain drag steps by days");
h.get_by_label("Auto Fill Options").click();
h.run_steps(3);
h.get_by_label("Fill Months").click();
h.run_steps(3);
assert_eq!(values(&h, &["B2", "B3"]), [n(46081.0), n(46112.0)], "Feb 28 and Mar 31");
}
#[test]
fn a_number_drag_offers_no_date_fills() {
let mut h = harness(one_two);
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
h.get_by_label("Auto Fill Options").click();
h.run_steps(3);
assert!(h.query_by_label("Copy Cells").is_some());
assert!(h.query_by_label("Fill Days").is_none());
}
fn formatted(s: &mut Session) {
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap();
s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();
+206
View File
@@ -0,0 +1,206 @@
//! The auto-growing formula bar, the 5-line in-cell editor and the code editor widget.
use egui::{Event, Key, Modifiers, Rect, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::formula_bar::{BarLayout, MAX_LINES, one_line};
use gridcraft_ui_egui::widgets::{Lang, code_editor};
use gridcraft_ui_egui::{SheetApp, theme};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(session),
);
settle(&mut h);
h
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
/// Long enough for the 120 ms height ease to finish.
fn settle(h: &mut Harness) {
h.run_steps(16);
}
fn key(key: Key, modifiers: Modifiers) -> [Event; 2] {
[
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
]
}
fn type_text(h: &mut Harness, text: &str) {
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(2);
}
fn alt_enter(h: &mut Harness) {
h.input_mut().events.extend(key(Key::Enter, Modifiers::ALT));
h.run_steps(2);
}
fn layout(h: &Harness) -> BarLayout {
BarLayout::get(&h.ctx)
}
fn row_height(h: &Harness) -> f32 {
h.ctx.fonts_mut(|f| f.row_height(&theme::ui_font(13.0)))
}
fn same(a: Rect, b: Rect) -> bool {
(a.min - b.min).length() < 0.5 && (a.max - b.max).length() < 0.5
}
#[test]
fn bar_grows_to_five_lines_then_scrolls_with_name_box_pinned() {
let mut h = harness(blank());
// The one-line height is 30 px unless the font's rows need more (Windows' do).
let rh = row_height(&h);
let base = one_line(rh);
let one = layout(&h);
assert!((one.height - base).abs() < 0.5, "one line is {base} px, got {}", one.height);
assert_eq!(one.lines, 1);
h.state_mut().begin_edit(None, true);
h.run_steps(2);
type_text(&mut h, "line 1");
for n in 2..=8 {
alt_enter(&mut h);
type_text(&mut h, &format!("line {n}"));
settle(&mut h);
let l = layout(&h);
assert_eq!(l.lines, n, "the editor holds {n} lines");
let expect = base + (n.min(MAX_LINES) - 1) as f32 * rh;
assert!((l.height - expect).abs() < 0.5, "{n} lines: height {} expected {expect}", l.height);
assert!(same(l.name_box, one.name_box), "the Name Box stays on the first line ({n} lines)");
assert!(same(l.buttons, one.buttons), "the buttons stay on the first line ({n} lines)");
let caret = l.caret.expect("a caret while editing");
assert!(l.field.contains_rect(caret.shrink(0.5)), "the caret stays visible at {n} lines: {caret:?} in {:?}", l.field);
}
assert_eq!(layout(&h).visible_lines, MAX_LINES);
// Enter commits the multi-line text; viewing the cell keeps the bar at five lines.
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
settle(&mut h);
let text = (1..=8).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let d = h.state().session.active().unwrap();
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::from(text.as_str()));
h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap();
settle(&mut h);
let viewing = layout(&h);
assert_eq!(viewing.lines, 8);
assert!((viewing.height - (base + 4.0 * rh)).abs() < 0.5);
// An empty cell shrinks it back to one line.
h.state_mut().session.execute("selection.set", json!({"cell": "B1"})).unwrap();
settle(&mut h);
assert!((layout(&h).height - base).abs() < 0.5);
}
#[test]
fn enter_that_closes_a_message_box_adds_no_line_to_the_entry() {
// A message box over the editor (a data validation alert) takes Enter to close. The bar's
// field is multi-line, and a field of its own would add a line on that Enter; the editor
// handles Enter itself, so the field does nothing with it.
let mut h = harness(blank());
h.state_mut().begin_edit(None, true);
h.run_steps(2);
type_text(&mut h, "abc");
h.state_mut().message = Some(("Stop".into(), "Whole numbers only.".into()));
h.run_steps(2);
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
h.run_steps(2);
assert!(h.state().message.is_none(), "Enter closed the message box");
assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("abc"), "and added nothing to the entry");
assert_eq!(layout(&h).lines, 1);
}
#[test]
fn chevron_pins_the_bar_at_five_lines() {
let mut h = harness(blank());
h.state_mut().ui.formula_bar_expanded = true;
settle(&mut h);
let rh = row_height(&h);
let base = one_line(rh);
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
h.state_mut().ui.formula_bar_expanded = false;
settle(&mut h);
assert!((layout(&h).height - base).abs() < 0.5);
}
#[test]
fn height_eases_instead_of_jumping() {
let mut h = harness(blank());
h.state_mut().ui.formula_bar_expanded = true;
h.run_steps(2);
let mid = layout(&h).height;
let rh = row_height(&h);
let base = one_line(rh);
assert!(mid > base + 0.5 && mid < base + 4.0 * rh - 0.5, "mid-ease height {mid}");
settle(&mut h);
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
}
fn in_cell_height(lines: usize) -> f32 {
let mut s = blank();
s.execute("selection.set", json!({"cell": "B3"})).unwrap();
let mut h = harness(s);
let text = (1..=lines).map(|n| format!("row {n}")).collect::<Vec<_>>().join("\n");
h.state_mut().begin_edit(Some(text.clone()), false);
settle(&mut h);
assert_eq!(h.state().editor.as_ref().unwrap().text, text, "the text is kept whole");
let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::unique("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted");
rect.height()
}
#[test]
fn in_cell_editor_grows_with_every_line() {
// The formula bar stops at five lines and scrolls; the in-cell editor shows every line, as
// the grid's own tests ask (`an_unwrapped_multiline_edit_shows_every_line`).
let (four, five, nine) = (in_cell_height(4), in_cell_height(5), in_cell_height(9));
assert!(five > four + 1.0, "grows with the lines ({four} then {five})");
assert!(nine > five + 1.0, "and past five ({five} then {nine})");
}
#[test]
fn code_editor_indents_and_keeps_indentation() {
struct State {
text: String,
focused: bool,
}
let mut h = crate::common::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|ui, s: &mut State| {
let id = egui::Id::unique("code");
if !s.focused {
ui.memory_mut(|m| m.request_focus(id));
s.focused = true;
}
code_editor(ui, id, &mut s.text, Lang::Python, 5);
},
State { text: String::new(), focused: false },
);
h.run_steps(2);
// The code editor's text field has no visible label; the strict policy checks its name.
h.check_accessibility();
h.input_mut().events.push(Event::Text("if x:".into()));
h.run_steps(1);
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
h.run_steps(1);
h.input_mut().events.push(Event::Text("y = 1".into()));
h.run_steps(1);
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
h.run_steps(1);
h.input_mut().events.extend(key(Key::Tab, Modifiers::NONE));
h.run_steps(1);
h.input_mut().events.push(Event::Text("z".into()));
h.run_steps(1);
assert_eq!(h.state().text, "if x:\n y = 1\n z");
}
@@ -0,0 +1,95 @@
//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar.
use egui::{Event, Key, Modifiers};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
/// Ctrl+Shift on Windows and Linux sets `ctrl`, `command` and `shift`.
const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: true };
/// Control+Shift on a Mac: `ctrl` without `command`.
const MAC_CONTROL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: false };
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap();
session.execute("selection.set", json!({"range": "A1"})).unwrap();
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(4);
h
}
fn ctrl_shift_u(h: &mut Harness, m: Modifiers) {
h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: true, repeat: false, modifiers: m });
h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: false, repeat: false, modifiers: m });
h.run_steps(2);
}
fn expanded(h: &Harness) -> bool {
h.state().ui.formula_bar_expanded
}
fn underlined(h: &Harness) -> bool {
h.state().session.journal.iter().any(|(id, _)| id == "home.underline")
}
#[test]
fn ctrl_shift_u_on_the_grid_expands_and_collapses_the_formula_bar() {
let mut h = harness();
assert!(!expanded(&h));
ctrl_shift_u(&mut h, CTRL_SHIFT);
assert!(expanded(&h), "the first Ctrl+Shift+U expands the formula bar");
ctrl_shift_u(&mut h, CTRL_SHIFT);
assert!(!expanded(&h), "the second Ctrl+Shift+U collapses it");
assert!(!underlined(&h), "Ctrl+Shift+U is not Underline");
}
#[test]
fn control_shift_u_expands_the_formula_bar_in_excel_for_mac() {
let mut h = harness();
h.ctx.set_os(egui::os::OperatingSystem::Mac);
h.run_steps(2);
ctrl_shift_u(&mut h, MAC_CONTROL_SHIFT);
assert!(expanded(&h));
assert!(h.state().editor.is_none(), "Control+Shift+U does not start editing as Control+U does");
let expand = Some(Shortcut::Run("view.expandFormulaBar", json!({})));
assert_eq!(lookup(Key::U, Some(Key::U), MAC_CONTROL_SHIFT, true), expand);
assert_eq!(lookup(Key::U, Some(Key::U), CTRL_SHIFT, false), expand);
// Control+U on a Mac still edits the active cell.
assert_eq!(lookup(Key::U, Some(Key::U), Modifiers::CTRL, true), Some(Shortcut::Edit));
}
#[test]
fn ctrl_shift_u_while_editing_keeps_the_entry() {
let mut h = harness();
h.state_mut().begin_edit(Some("draft".into()), false);
h.run_steps(2);
ctrl_shift_u(&mut h, CTRL_SHIFT);
assert!(expanded(&h), "Ctrl+Shift+U works in the cell editor too");
let ed = h.state().editor.as_ref().expect("the edit stays open");
assert_eq!(ed.text, "draft");
ctrl_shift_u(&mut h, CTRL_SHIFT);
assert!(!expanded(&h));
assert!(h.state().editor.is_some());
}
#[test]
fn expand_formula_bar_command_sets_or_toggles_the_state() {
let mut h = harness();
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true})));
assert!(expanded(&h));
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true})));
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false})));
assert!(!expanded(&h));
}
+89
View File
@@ -0,0 +1,89 @@
//! The Go To dialog lists the named ranges of every sheet and the references entered before; a
//! double-click on an entry goes there.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::control::{Outcome, handle};
use serde_json::{Value as Json, json};
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
session.execute("selection.set", json!({"sheet": 0})).unwrap();
for (name, refers, scope) in [("Price", "=Sheet1!$B$2:$B$5", "Workbook"), ("Local", "=Sheet2!$C$3", "Sheet2"), ("Rate", "=0.07", "Workbook")] {
session.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
}
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(4);
h
}
fn control(h: &mut Harness, method: &str, params: Json) -> Json {
let ctx = h.ctx.clone();
let out = match handle(h.state_mut(), &ctx, method, &params) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!(null),
};
h.run_steps(2);
out
}
fn button(h: &mut Harness, pos: Pos2, pressed: bool) {
h.input_mut()
.events
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
h.step();
}
fn double_click(h: &mut Harness, label: &str) {
let pos = h.get_by_label(label).rect().center();
button(h, pos, true);
button(h, pos, false);
button(h, pos, true);
button(h, pos, false);
h.run_steps(2);
}
fn place(h: &Harness) -> (usize, String) {
let d = h.state().session.active().unwrap();
(d.wb.active_sheet, d.selection.a1())
}
#[test]
fn double_clicking_a_name_scoped_to_another_sheet_goes_there() {
let mut h = harness();
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
assert!(h.query_by_label("Price").is_some(), "a workbook range name is listed");
assert!(h.query_by_label("Rate").is_none(), "a constant is not a named range");
double_click(&mut h, "Sheet2!Local");
assert!(h.state().dialog.is_none(), "the dialog closed");
assert_eq!(place(&h), (1, "C3".to_string()));
}
#[test]
fn a_reference_entered_is_listed_and_a_double_click_returns_to_it() {
let mut h = harness();
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
control(&mut h, "ui.dialog.set", json!({"field": "reference", "value": "B14:C22,F19:G30"}));
h.key_press(Key::Enter);
h.run_steps(2);
assert!(h.state().dialog.is_none(), "Enter went");
assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string()));
control(&mut h, "selection.set", json!({"sheet": 1}));
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
double_click(&mut h, "Sheet1!B14:C22,Sheet1!F19:G30");
assert!(h.state().dialog.is_none(), "the dialog closed");
assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string()));
}
+89
View File
@@ -389,6 +389,30 @@ fn inserted_chart_is_selected_so_delete_removes_it() {
assert_eq!(value(&h, "A1"), Value::Number(1.0));
}
#[test]
fn insert_column_or_bar_menu_inserts_a_hundred_percent_stacked_bar() {
// Insert › Column or Bar (keytip N, C1) lists the column and bar types of the All Charts list.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:C3"})).unwrap();
let mut h = harness(s);
key(&mut h, Key::F10, Modifiers::NONE);
for (k, t) in [(Key::N, "n"), (Key::C, "c"), (Key::Num1, "1")] {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Text(t.into()));
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
h.run_steps(2);
for label in ["Clustered Column", "Stacked Column", "100% Stacked Column", "Clustered Bar", "Stacked Bar", "100% Stacked Bar"] {
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, label).is_some(), "the menu lists {label}");
}
h.get_by_role_and_label(egui::accesskit::Role::Button, "100% Stacked Bar").click();
h.run_steps(2);
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::BarStacked100]);
}
#[test]
fn renders_every_ribbon_tab_without_panicking() {
let mut s = Session::new();
@@ -572,6 +596,34 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() {
assert!(h.state().message.is_some());
}
#[test]
fn all_charts_inserts_a_hundred_percent_stacked_area() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:C3"})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("recommendedCharts", json!({"tab": "All Charts"}));
h.run_steps(4);
// The entry sits below the list's visible height: scroll the list with the wheel first.
let first = h.get_by_label("Pie").rect().center();
h.input_mut().events.push(Event::PointerMoved(first));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
source: egui::MouseWheelSource::Unknown,
delta: egui::vec2(0.0, -10.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
});
h.run_steps(20);
h.get_by_label("100% Stacked Area").click();
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().dialog.is_none());
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::AreaStacked100]);
}
#[test]
fn recommended_charts_command_without_a_type_opens_the_dialog() {
// #1399: the ribbon button and the NR keytip ran the command directly and inserted the first
@@ -937,6 +989,43 @@ fn status_bar_shows_macro_badge() {
h.run_steps(2);
}
#[test]
fn status_bar_names_a_circular_reference_by_its_address() {
let mut s = blank();
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
let h = harness(s);
assert!(h.query_by_label("Circular References: B2").is_some(), "the status bar names the cell on the active sheet without its sheet");
}
#[test]
fn status_bar_names_no_cell_when_every_circular_reference_is_on_another_sheet() {
let mut s = blank();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let mut h = harness(s);
assert!(h.query_by_label("Circular References").is_some(), "the status bar still reports circular references");
assert!(h.query_by_label("Circular References: B2").is_none(), "no cell address from another sheet");
h.state_mut().session.execute("sheet.activate", json!({"sheet": "Data"})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Circular References: B2").is_some(), "the active sheet's loop is named");
}
#[test]
fn status_bar_names_a_circular_reference_on_the_active_sheet() {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "=A1+1"})).unwrap();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
s.execute("formulas.calculateNow", json!({})).unwrap();
let circular = s.active().unwrap().calc.circular.clone();
assert_eq!(circular, vec![(0, CellRef::new(0, 0)), (1, CellRef::new(1, 1))], "Sheet1's loop is listed first");
let h = harness(s);
assert!(h.query_by_label("Circular References: B2").is_some(), "the status bar names the loop on the active sheet");
assert!(h.query_by_label("Circular References: A1").is_none(), "not the first loop listed");
}
#[test]
fn alt_f8_opens_the_macros_dialog() {
let mut h = harness(blank());
+1 -4
View File
@@ -1783,10 +1783,7 @@ fn keytip_ms_() {
fn keytip_msp_() {
let mut h = harness(blank());
sequence(&mut h, "MSP", false);
assert!(h.state().dialog.is_none(), "MSP must not open a dialog");
assert!(h.state().message.is_none(), "MSP must not open a message box");
assert!(h.state().toast.is_none(), "MSP must not raise a toast");
assert!(h.state().session.journal.is_empty(), "MSP must not journal");
assert!(h.state().dialog.is_some(), "MSP must open the pasteName dialog");
}
#[test]
+9
View File
@@ -27,6 +27,7 @@ mod dialog_confirm;
mod dialog_focus;
mod dialog_move;
mod drag_cursor;
mod error_checking_menu;
mod file_menu;
mod fill_handle_double_click;
mod fill_handle_right_drag;
@@ -37,10 +38,13 @@ mod find_dialog_focus;
mod floating_pictures;
mod font_size_box;
mod format_painter;
mod formula_bar;
mod formula_bar_expand;
mod formula_locale;
mod formula_point_headers;
mod formula_popup;
mod freeze_handles;
mod go_to_dialog;
mod header_autofit;
mod header_column_drag;
mod host_chrome;
@@ -62,11 +66,14 @@ mod open_encrypted;
mod outline;
mod page_layout_view;
mod page_setup_margins;
mod paste_names;
mod paste_special_dialog;
mod pivot_pane;
mod pivot_show_details;
mod pivot_show_details_menu;
mod popup_dismissal;
mod protect_password;
mod ribbon_layout;
mod right_to_left;
mod scroll_position;
mod scrollbar_column_fill;
@@ -80,8 +87,10 @@ mod sheet_tabs;
mod show_objects;
mod shrink_to_fit;
mod sort_dialog_columns;
mod status_bar_menu;
mod symbol_glyphs;
mod system_theme;
mod tab_split;
mod table_headings;
mod text_box_editing;
mod text_overflow_scroll;
+29 -1
View File
@@ -1,4 +1,5 @@
//! The outline band of grouped rows and columns: − / + and level buttons (#373).
//! The outline band of grouped rows and columns: − / + and level buttons (#373), and the
//! Outline Settings dialog that moves the summary rows above their detail.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use gridcraft_engine::Session;
@@ -90,3 +91,30 @@ fn outline_buttons_collapse_expand_and_undo() {
fn outline_buttons_work_on_right_to_left_sheets() {
outline_buttons_collapse_and_expand(true);
}
#[test]
fn settings_dialog_puts_summary_rows_above_their_detail() {
use egui_kittest::kittest::Queryable as _;
let mut h = harness();
h.state_mut().session.run("data.group", json!({"rows": "5:8"})).unwrap();
h.state_mut().open_dialog("outlineSettings", json!({}));
h.run_steps(4);
h.get_by_label("Summary rows below detail").click();
h.run_steps(3);
h.get_by_label_contains("OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert!(!sh.summary_below && sh.summary_right);
// The − now sits on row 4, above the detail; it hides rows 5:8 and marks row 4 collapsed.
let minus = button(&h, toggle(true, 1));
let geo = h.state().grid.geo.clone().unwrap();
let row4 = geo.cell_rect(&sh, gridcraft_engine::core::CellRef::new(3, 0));
assert!(row4.y_range().contains(minus.y), "{minus:?} is not on row 4 ({row4:?})");
click(&mut h, minus);
assert_eq!(hidden_rows(&h), [4, 5, 6, 7]);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert!(sh.rows.get(&3).is_some_and(|l| l.collapsed));
assert!(!sh.rows.get(&8).is_some_and(|l| l.collapsed));
}
@@ -2,6 +2,7 @@
//! transform, and headers are edited in place, committed on a click elsewhere, and undoable.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_engine::model::SheetViewMode;
@@ -160,6 +161,27 @@ fn the_first_page_edits_its_own_header_when_different_first_page_is_on() {
assert_eq!(pr.header, "&LLeft", "the other pages keep their header");
}
#[test]
fn scale_with_document_and_align_with_page_margins_are_check_boxes_on_the_header_and_footer_tab() {
let mut h = fixture();
gridcraft_ui_egui::paper_view::start_editing(h.state_mut());
h.run_steps(4);
let options = |h: &Harness| {
let pr = &h.state().session.doc().unwrap().wb.active().unwrap().print;
(pr.hf_scale_with_doc, pr.hf_align_with_margins)
};
assert_eq!(options(&h), (true, true));
h.get_by_label("Scale with Document").click();
h.run_steps(3);
assert_eq!(options(&h), (false, true));
h.get_by_label("Align with Page Margins").click();
h.run_steps(3);
assert_eq!(options(&h), (false, false));
assert!(h.state().grid.hf_edit.is_some(), "the header stays in edit");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(options(&h), (false, true));
}
#[test]
fn insert_header_and_footer_switches_to_page_layout_view() {
let mut h = fixture();
+161
View File
@@ -0,0 +1,161 @@
//! F3 opens the Paste Name dialog when the workbook has names: OK puts the selected name into the
//! formula being edited, and Paste List writes every name and what it refers to, as in Excel.
use egui::{Event, Key, vec2};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::control::{Outcome, handle};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(names: bool) -> Harness {
let mut session = Session::new();
session.new_workbook();
if names {
session.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
session.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.07"})).unwrap();
}
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(6);
h
}
fn dialog(h: &Harness) -> Option<&str> {
h.state().dialog.as_ref().map(|d| d.name())
}
fn editor_text(h: &Harness) -> Option<String> {
h.state().editor.as_ref().map(|e| e.text.clone())
}
fn value(h: &Harness, cell: &str) -> Value {
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
fn press(h: &mut Harness, key: Key) {
h.key_press(key);
h.run_steps(3);
}
#[test]
fn f3_opens_paste_name_only_when_the_workbook_has_names() {
let mut h = harness(false);
press(&mut h, Key::F3);
assert_eq!(dialog(&h), None, "no names: F3 does nothing");
let mut h = harness(true);
press(&mut h, Key::F3);
assert_eq!(dialog(&h), Some("pasteName"));
// The names are listed in alphabetical order.
let rate = h.get_by_label("Rate").rect().top();
let sales = h.get_by_label("Sales").rect().top();
assert!(rate < sales, "Rate is listed above Sales");
}
#[test]
fn ok_starts_a_formula_with_the_selected_name() {
let mut h = harness(true);
press(&mut h, Key::F3);
h.get_by_label("Sales").click();
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(3);
assert_eq!(dialog(&h), None);
assert_eq!(editor_text(&h).as_deref(), Some("=Sales"));
}
#[test]
fn the_control_channel_confirms_the_dialog_like_ok() {
let mut h = harness(true);
let ctx = h.ctx.clone();
for (method, params) in
[("ui.dialog", json!({"name": "pasteName"})), ("ui.dialog.set", json!({"field": "name", "value": "Sales"})), ("ui.dialog.confirm", json!({}))]
{
match handle(h.state_mut(), &ctx, method, &params) {
Outcome::Done(v) => assert_eq!(v["ok"], json!(true), "{method}: {v}"),
Outcome::Screenshot { .. } => panic!("{method} took a screenshot"),
}
}
h.run_steps(2);
assert_eq!(dialog(&h), None);
assert_eq!(editor_text(&h).as_deref(), Some("=Sales"));
}
#[test]
fn arrow_keys_choose_the_name_and_leave_the_active_cell() {
let mut h = harness(true);
press(&mut h, Key::F3);
press(&mut h, Key::ArrowDown);
press(&mut h, Key::Enter);
assert_eq!(dialog(&h), None);
assert_eq!(editor_text(&h).as_deref(), Some("=Sales"), "Down moved from Rate to Sales");
let active = h.state().session.active().unwrap().selection.active;
assert_eq!(active, CellRef::new(0, 0), "the arrow went to the dialog, not the grid");
}
#[test]
fn while_editing_the_name_goes_in_at_the_caret_and_the_edit_goes_on() {
let mut h = harness(true);
h.state_mut().begin_edit(Some("=SUM(".into()), false);
h.run_steps(4);
press(&mut h, Key::F3);
assert_eq!(dialog(&h), Some("pasteName"), "F3 in the cell editor opens the dialog");
// While the dialog is open, typed text and arrow keys do not change the formula being edited.
h.input_mut().events.push(Event::Text("x".into()));
h.run_steps(2);
press(&mut h, Key::ArrowLeft);
assert_eq!(editor_text(&h).as_deref(), Some("=SUM("), "the formula is unchanged while the dialog is open");
h.get_by_label("Sales").click();
h.run_steps(2);
press(&mut h, Key::Enter);
assert_eq!(dialog(&h), None, "Enter is the dialog's OK");
assert_eq!(editor_text(&h).as_deref(), Some("=SUM(Sales"), "the cell is still being edited");
assert_eq!(value(&h, "A1"), Value::Empty, "nothing was entered yet");
}
#[test]
fn cancel_leaves_the_selected_text_selected() {
let mut h = harness(true);
h.state_mut().begin_edit(Some("=SUM(A1".into()), false);
// Edit mode (F2): the arrow keys move the caret instead of leaving the cell.
if let Some(ed) = h.state_mut().editor.as_mut() {
ed.enter_mode = false;
}
h.run_steps(4);
for _ in 0..2 {
h.key_press_modifiers(egui::Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(2);
}
press(&mut h, Key::F3);
assert_eq!(dialog(&h), Some("pasteName"));
h.get_by_label(" Cancel ").click();
h.run_steps(3);
assert_eq!(dialog(&h), None);
h.input_mut().events.push(Event::Text("B2".into()));
h.run_steps(2);
assert_eq!(editor_text(&h).as_deref(), Some("=SUM(B2"), "typing replaces the text selected before the dialog opened");
}
#[test]
fn paste_list_writes_the_names_from_the_active_cell() {
let mut h = harness(true);
h.state_mut().run("selection.set", json!({"range": "C2"})).unwrap();
h.run_steps(2);
press(&mut h, Key::F3);
h.get_by_label("Paste List").click();
h.run_steps(3);
assert_eq!(dialog(&h), None);
assert_eq!(value(&h, "C2"), Value::text("Rate"));
assert_eq!(value(&h, "D2"), Value::text("=0.07"));
assert_eq!(value(&h, "C3"), Value::text("Sales"));
assert_eq!(value(&h, "D3"), Value::text("=Sheet1!$A$1:$A$3"));
assert_eq!(editor_text(&h), None);
}
+16 -1
View File
@@ -1,5 +1,6 @@
//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's
//! Show Details); a label of the report still opens the cell for editing.
//! Show Details) unless its Enable show details option is off; a label of the report still opens
//! the cell for editing.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use gridcraft_engine::Session;
@@ -104,3 +105,17 @@ fn double_click_in_an_out_of_date_report_asks_for_a_refresh() {
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message);
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
}
#[test]
fn double_click_with_enable_show_details_off_says_why_and_does_not_edit() {
let mut h = harness();
h.state_mut().session.execute("pivot.options", json!({"pivot": "PivotTable1", "enableShowDetails": false})).unwrap();
h.run_steps(2);
let pos = center(&h, "E4");
double_click(&mut h, pos);
let app = h.state();
assert!(app.editor.is_none(), "the value cell must not open for editing");
assert!(app.message.as_ref().is_some_and(|(_, m)| m == "Show Details is turned off for this PivotTable."), "{:?}", app.message);
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
}
@@ -0,0 +1,143 @@
//! Right-clicking a PivotTable's value cell offers Show Details in the cell menu (Excel's
//! right-click › Show Details); labels of the report and ordinary cells don't offer it.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap();
// Report at D3: D4 East, E4 its Sum of Sales, E6 the Grand Total.
session
.execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]}))
.unwrap();
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(4);
h
}
fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center()
}
fn right_click(h: &mut egui_kittest::Harness<'_, SheetApp>, cell: &str) {
let pos = center(h, cell);
h.input_mut().events.push(Event::PointerMoved(pos));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
assert!(h.state().grid.context_menu.is_some(), "the cell menu opens on {cell}");
}
#[test]
fn show_details_in_the_cell_menu_lists_a_value_cells_records_on_a_new_sheet() {
let mut h = harness();
right_click(&mut h, "E4");
h.get_by_label("Show Details").click();
h.run_steps(2);
let app = h.state();
assert!(app.grid.context_menu.is_none(), "the menu closes");
let wb = &app.session.active().unwrap().wb;
assert_eq!(wb.sheets.len(), 2);
let sheet = wb.active().unwrap();
let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display();
assert_eq!(
[text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")],
["Region", "Sales", "East", "100", "East", "30", ""]
);
}
#[test]
fn show_details_in_the_cell_menu_on_the_grand_total_takes_every_record() {
let mut h = harness();
right_click(&mut h, "E6");
h.get_by_label("Show Details").click();
h.run_steps(2);
let wb = &h.state().session.active().unwrap().wb;
assert_eq!(wb.sheets.len(), 2);
let sheet = wb.active().unwrap();
assert_eq!(sheet.value(CellRef::parse("B4").unwrap()).display(), "30");
}
#[test]
fn labels_of_the_report_and_ordinary_cells_have_no_show_details_item() {
let mut h = harness();
for cell in ["D4", "D3", "A2"] {
right_click(&mut h, cell);
assert!(h.query_by_label("Sort A to Z").is_some(), "the cell menu is up on {cell}");
assert!(h.query_by_label("Show Details").is_none(), "{cell} offers no Show Details");
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
h.input_mut().events.push(escape);
h.run_steps(2);
}
}
#[test]
fn no_cell_offers_show_details_while_it_is_turned_off() {
let mut h = harness();
h.state_mut().session.execute("pivot.options", json!({"enableShowDetails": false, "pivot": "PivotTable1"})).unwrap();
h.run_steps(2);
for cell in ["E4", "E6"] {
right_click(&mut h, cell);
assert!(h.query_by_label("Sort A to Z").is_some(), "the cell menu is up on {cell}");
assert!(h.query_by_label("Show Details").is_none(), "{cell} offers no Show Details");
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
h.input_mut().events.push(escape);
h.run_steps(2);
}
// Turned back on, the value cell offers it again.
h.state_mut().session.execute("pivot.options", json!({"enableShowDetails": true, "pivot": "PivotTable1"})).unwrap();
h.run_steps(2);
right_click(&mut h, "E4");
assert!(h.query_by_label("Show Details").is_some(), "E4 offers Show Details");
}
#[test]
fn the_cell_menu_computes_the_report_once_for_all_its_cells() {
let mut h = harness();
right_click(&mut h, "E4");
assert!(h.query_by_label("Show Details").is_some());
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
h.input_mut().events.push(escape);
h.run_steps(2);
// West goes away behind the session's back, so the revision stays the same. A menu that
// computed the report again for D4 would find it out of date and offer Show Details there.
let doc = h.state_mut().session.active_mut().unwrap();
let si = doc.wb.active_sheet;
std::sync::Arc::make_mut(&mut doc.wb).sheet_mut(si).unwrap().set_value(CellRef::parse("A3").unwrap(), Value::text("East"));
right_click(&mut h, "D4");
assert!(h.query_by_label("Show Details").is_none(), "D4 is a label of the report computed for E4");
}
#[test]
fn show_details_in_an_out_of_date_report_asks_for_a_refresh() {
let mut h = harness();
// West goes away without a refresh: the report on the sheet is no longer what the source gives.
h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap();
h.run_steps(2);
right_click(&mut h, "E4");
h.get_by_label("Show Details").click();
h.run_steps(2);
let app = h.state();
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message);
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
}
+149
View File
@@ -0,0 +1,149 @@
//! Ribbon layout: the big split button, the scroll arrows of an overflowing tab, and every tab
//! fitting the card's height.
use egui::vec2;
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::icons::Icon;
use gridcraft_ui_egui::ribbon::{RibbonOverflow, TABS};
use gridcraft_ui_egui::{SheetApp, widgets};
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn app_at(width: f32, tab: &str) -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut app = crate::common::app(session);
app.ui.ribbon_tab = tab.into();
let mut h = crate::common::builder().with_size(vec2(width, 700.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
#[test]
fn every_tab_fits_the_card_height() {
// Columns of small buttons hold three rows; a fourth would be cut off at the card's bottom.
for tab in TABS {
let h = app_at(1440.0, tab);
let o = RibbonOverflow::get(&h.ctx);
assert!(o.visible_height > 0.0, "{tab}: the card was laid out");
assert!(o.content_height <= o.visible_height + 0.5, "{tab}: {} px of groups in {} px", o.content_height, o.visible_height);
}
}
#[test]
fn an_overflowing_tab_shows_scroll_arrows_that_scroll() {
let mut h = app_at(700.0, "Home");
let o = RibbonOverflow::get(&h.ctx);
assert!(o.overflows(), "Home is wider than a 700 px window");
assert_eq!(o.offset, 0.0);
assert!(h.query_by_label("Scroll ribbon left").is_none(), "nothing to the left yet");
h.get_by_label("Scroll ribbon right").click();
h.run_steps(3);
let scrolled = RibbonOverflow::get(&h.ctx).offset;
assert!(scrolled > 0.0);
h.get_by_label("Scroll ribbon left").click();
h.run_steps(3);
assert!(RibbonOverflow::get(&h.ctx).offset < scrolled);
// A tab that fits shows no arrows.
let h = app_at(1440.0, "Automate");
assert!(!RibbonOverflow::get(&h.ctx).overflows());
assert!(h.query_by_label("Scroll ribbon right").is_none());
}
#[test]
fn each_tab_keeps_a_scroll_offset_of_its_own() {
// Scrolling Home must not hide the first groups of another overflowing tab, and Home keeps
// its offset while the other tab is used.
let mut h = app_at(700.0, "Home");
h.get_by_label("Scroll ribbon right").click();
h.run_steps(3);
let home_offset = RibbonOverflow::get(&h.ctx).offset;
assert!(home_offset > 0.0);
let other = TABS.iter().copied().find(|t| *t != "Home" && RibbonOverflow::get(&app_at(700.0, t).ctx).overflows());
let other = other.expect("another tab overflows a 700 px window");
h.state_mut().ui.ribbon_tab = other.into();
h.run_steps(3);
let o = RibbonOverflow::get(&h.ctx);
assert!(o.overflows(), "{other} overflows");
assert_eq!(o.offset, 0.0, "{other} starts at its left edge");
h.get_by_label("Scroll ribbon right").click();
h.run_steps(3);
assert!(RibbonOverflow::get(&h.ctx).offset > 0.0);
h.state_mut().ui.ribbon_tab = "Home".into();
h.run_steps(3);
assert_eq!(RibbonOverflow::get(&h.ctx).offset, home_offset, "Home kept its offset");
}
#[test]
fn the_scroll_arrows_stay_inside_a_narrow_window() {
// The tab row can be wider than a narrow window (more so with wider system fonts); the card
// and its arrows stay inside the window, and the right arrow still scrolls.
for width in [600.0, 480.0] {
let mut h = app_at(width, "Home");
let arrow = h.get_by_label("Scroll ribbon right").rect();
assert!(arrow.max.x <= width, "{width}: the arrow ends at {}", arrow.max.x);
h.get_by_label("Scroll ribbon right").click();
h.run_steps(3);
assert!(RibbonOverflow::get(&h.ctx).offset > 0.0, "{width}: the right arrow scrolls");
}
}
#[derive(Default)]
struct Clicks {
main: u32,
arrow: u32,
}
#[test]
fn big_split_button_separates_the_default_action_from_the_menu() {
let mut h = crate::common::builder().with_size(vec2(300.0, 200.0)).build_ui_state(
|ui, c: &mut Clicks| {
let (main, arrow) = widgets::big_split_button(ui, Icon::Sum, "Auto\nSum", "AutoSum");
c.main += u32::from(main.clicked());
c.arrow += u32::from(arrow.clicked());
},
Clicks::default(),
);
h.run_steps(2);
h.get_by_label("Auto Sum").click();
h.run_steps(2);
h.get_by_label("Auto Sum options").click();
h.run_steps(2);
h.get_by_label("Auto Sum options").click();
h.run_steps(2);
assert_eq!((h.state().main, h.state().arrow), (1, 2));
// The tooltip carries the shortcut hint, as every ribbon button's does. egui 0.37 waits for
// the pointer to settle before opening it, so remove the hover delay and let it settle.
h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0);
h.get_by_label("Auto Sum").hover();
h.run_steps(6);
let shortcut = if cfg!(target_os = "macos") { "AutoSum (\u{2318}\u{21e7}T)" } else { "AutoSum (Alt+=)" };
assert!(h.query_by_value(shortcut).is_some(), "tooltip with the shortcut");
// The icon part sits above the label part, across the full width.
let main = h.get_by_label("Auto Sum").rect();
let arrow = h.get_by_label("Auto Sum options").rect();
assert!(main.max.y <= arrow.min.y + 0.5);
assert!((main.min.x - arrow.min.x).abs() < 0.5 && (main.max.x - arrow.max.x).abs() < 0.5);
}
#[test]
fn formulas_autosum_runs_on_the_icon_and_opens_its_menu_on_the_label() {
let mut h = app_at(1600.0, "Formulas");
h.get_by_label("AutoSum").click();
h.run_steps(2);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the icon runs AutoSum");
assert!(h.query_by_label("AVERAGE").is_none());
h.get_by_label("AutoSum options").click();
h.run_steps(2);
h.get_by_label("AVERAGE").click();
h.run_steps(2);
let last = h.state().session.journal.iter().rev().find(|(id, _)| id == "formulas.autoSum").map(|(_, p)| p.clone());
assert_eq!(last.and_then(|p| p.get("function").and_then(|f| f.as_str().map(str::to_string))).as_deref(), Some("AVERAGE"));
}
@@ -0,0 +1,60 @@
//! Right-clicking the status bar opens a menu that turns its selection statistics on and off.
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "9")] {
session.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
session.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(4);
h
}
fn right_click(h: &mut Harness, pos: Pos2) {
h.input_mut().events.push(Event::PointerMoved(pos));
h.run_steps(1);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(3);
}
#[test]
fn right_clicking_the_status_bar_turns_on_minimum() {
let mut h = harness();
assert!(h.query_by_label("Sum: 12").is_some(), "Sum is on by default");
assert!(h.query_by_label("Minimum: -1").is_none(), "Minimum is off by default");
// An empty stretch of the 26-point status bar, between the mode text and the statistics.
right_click(&mut h, pos2(300.0, 787.0));
h.get_by_label("Minimum").click();
h.run_steps(4);
assert!(h.state().session.prefs.status_bar.minimum);
assert!(h.query_by_label("Minimum: -1").is_some(), "the status bar shows the minimum of the selection");
}
#[test]
fn right_clicking_the_status_bar_text_opens_the_menu() {
for text in ["Sum: 12", "Ready"] {
let mut h = harness();
let pos = h.get_by_label(text).rect().center();
right_click(&mut h, pos);
assert!(h.query_by_label("Numerical Count").is_some(), "a right-click on {text:?} opens the statistics menu");
}
}
+153
View File
@@ -0,0 +1,153 @@
//! The handle between the sheet tabs and the horizontal scrollbar: dragging it gives the tabs more
//! or less of the tab bar, as in Excel.
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, Shape, pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(width: f32, sheets: usize) -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap();
for i in 1..sheets {
s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap();
}
s.execute("sheet.activate", json!({"sheet":0})).unwrap();
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state(
move |ui, app: &mut SheetApp| {
app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0)));
gridcraft_ui_egui::tabs::sheet_tabs(app, ui);
},
crate::common::app(s),
);
h.run_steps(20);
h
}
fn split(h: &Harness) -> Rect {
h.state().grid.tab_split.expect("the tab bar has a split handle")
}
fn track(h: &Harness) -> Rect {
h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar")
}
/// The clip rectangle of the tab strip: the viewport the tab names are painted in.
fn strip(h: &Harness) -> Rect {
h.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == "Tab00" => Some(s.clip_rect),
_ => None,
})
.expect("Tab00 was painted")
}
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
h.input_mut().events.extend([
Event::PointerMoved(from),
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
]);
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(20);
}
#[test]
fn dragging_the_handle_right_gives_overflowing_tabs_more_room() {
let mut h = harness(1200.0, 30);
let (before, handle) = (strip(&h), split(&h));
assert!(before.width() <= 1200.0 * 0.6, "the tabs start at their default share: {before:?}");
drag(&mut h, handle.center(), pos2(1000.0, handle.center().y));
let after = strip(&h);
assert!(after.width() > before.width() + 200.0, "the strip grew: {before:?} -> {after:?}");
assert!((split(&h).center().x - 1000.0).abs() <= 2.0, "the handle follows the pointer: {:?}", split(&h));
assert_eq!(track(&h).left(), split(&h).right(), "the scrollbar starts at the handle");
assert!(track(&h).width() >= 100.0, "the scrollbar keeps a usable width: {:?}", track(&h));
assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 30, "dragging the handle does not edit the workbook");
}
#[test]
fn dragging_the_handle_left_gives_the_scrollbar_more_room() {
let mut h = harness(1200.0, 30);
let (before, handle) = (track(&h), split(&h));
drag(&mut h, handle.center(), pos2(handle.center().x - 300.0, handle.center().y));
let after = track(&h);
assert!(after.width() > before.width() + 280.0, "the scrollbar grew: {before:?} -> {after:?}");
assert!(strip(&h).width() < 1200.0 * 0.6 - 280.0, "the strip shrank: {:?}", strip(&h));
}
#[test]
fn the_handle_stops_before_the_scrollbar_gets_too_narrow_and_keeps_new_sheet_shown() {
let mut h = harness(1200.0, 30);
let handle = split(&h);
drag(&mut h, handle.center(), pos2(1195.0, handle.center().y));
assert!(track(&h).width() >= 100.0 - 0.5, "the scrollbar keeps 100 px: {:?}", track(&h));
let (handle, bar_left) = (split(&h), strip(&h).left());
drag(&mut h, handle.center(), pos2(0.0, handle.center().y));
// The tab strip has no width left, and the New sheet button (26 px after a 6 px gap) still
// fits before the handle.
assert!((split(&h).left() - bar_left - 32.0).abs() <= 1.0, "the handle is 32 px after the strip's start {bar_left}: {:?}", split(&h));
}
#[test]
fn a_narrower_window_keeps_the_scrollbar_after_the_handle_was_dragged_right() {
let mut h = harness(1200.0, 30);
let handle = split(&h);
drag(&mut h, handle.center(), pos2(1195.0, handle.center().y));
h.set_size(vec2(700.0, 120.0));
h.run_steps(20);
let track = track(&h);
assert!(track.width() >= 100.0 - 0.5 && track.right() <= 700.5, "the scrollbar keeps 100 px in the bar: {track:?}");
assert_eq!(track.left(), split(&h).right(), "the scrollbar starts at the handle");
assert!(strip(&h).right() <= split(&h).left() - 32.0 + 0.5, "the tabs end before New sheet: {:?}", strip(&h));
}
#[test]
fn room_the_tabs_do_not_fill_stays_empty_and_new_sheet_follows_the_last_tab() {
let mut h = harness(1200.0, 2);
let plus_before = track(&h).left() - 8.0 - 26.0;
let handle = split(&h);
drag(&mut h, handle.center(), pos2(900.0, handle.center().y));
assert!((split(&h).center().x - 900.0).abs() <= 2.0, "the handle stays where it was dragged: {:?}", split(&h));
assert_eq!(track(&h).left(), split(&h).right());
// New sheet (+) is still right after the last tab: clicking there adds a sheet.
let at = pos2(plus_before + 13.0, handle.center().y);
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
]);
h.step();
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(20);
assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 3, "New sheet stayed after the last tab");
}
#[test]
fn the_handle_shows_the_resize_cursor() {
let mut h = harness(1200.0, 30);
let at = split(&h).center();
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal);
}
#[test]
fn each_workbook_keeps_its_own_tab_share() {
let mut h = harness(1200.0, 30);
let handle = split(&h);
drag(&mut h, handle.center(), pos2(1000.0, handle.center().y));
assert!(track(&h).left() > 990.0, "the first workbook's scrollbar starts at the handle: {:?}", track(&h));
h.state_mut().session.new_workbook();
h.run_steps(20);
// The new workbook's one tab takes its default room, so its scrollbar starts right after New sheet.
assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h));
assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share");
}
+8 -8
View File
@@ -40,7 +40,9 @@ fn group_kind(g: &El) -> Option<ChartKind> {
"pieChart" | "pie3DChart" | "ofPieChart" => ChartKind::Pie,
"doughnutChart" => ChartKind::Doughnut,
"areaChart" | "area3DChart" => {
if stacked || pct {
if pct {
ChartKind::AreaStacked100
} else if stacked {
ChartKind::AreaStacked
} else {
ChartKind::Area
@@ -284,7 +286,7 @@ enum G {
Line { stacked: bool, markers: bool },
Pie,
Doughnut,
Area { stacked: bool },
Area { grouping: &'static str },
Scatter { lines: bool },
Bubble,
Radar,
@@ -303,8 +305,9 @@ fn group_for(k: ChartKind) -> G {
ChartKind::LineStacked => G::Line { stacked: true, markers: false },
ChartKind::Pie => G::Pie,
ChartKind::Doughnut => G::Doughnut,
ChartKind::Area => G::Area { stacked: false },
ChartKind::AreaStacked => G::Area { stacked: true },
ChartKind::Area => G::Area { grouping: "standard" },
ChartKind::AreaStacked => G::Area { grouping: "stacked" },
ChartKind::AreaStacked100 => G::Area { grouping: "percentStacked" },
ChartKind::Scatter => G::Scatter { lines: false },
ChartKind::ScatterLines => G::Scatter { lines: true },
ChartKind::Bubble => G::Bubble,
@@ -671,10 +674,7 @@ fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb
G::Doughnut => {
format!("<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/><c:holeSize val=\"50\"/></c:doughnutChart>")
}
G::Area { stacked } => format!(
"<c:areaChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>",
if stacked { "stacked" } else { "standard" }
),
G::Area { grouping } => format!("<c:areaChart><c:grouping val=\"{grouping}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>"),
G::Scatter { .. } => format!("<c:scatterChart><c:scatterStyle val=\"lineMarker\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:scatterChart>"),
G::Bubble => {
format!("<c:bubbleChart><c:varyColors val=\"0\"/>{sers}{dl}<c:bubbleScale val=\"100\"/><c:showNegBubbles val=\"0\"/>{ax}</c:bubbleChart>")
+4
View File
@@ -818,6 +818,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
if !pt.show_headers {
s.push_str(" showHeaders=\"0\"");
}
if !pt.enable_show_details {
s.push_str(" enableDrill=\"0\"");
}
// `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"),
// `colGrandTotals` the grand total column at its right ("On for Rows").
if !pt.grand_totals_cols {
@@ -1282,6 +1285,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
pt.grand_totals_rows = x.flag("colGrandTotals", true);
pt.grand_totals_cols = x.flag("rowGrandTotals", true);
pt.show_headers = x.flag("showHeaders", true);
pt.enable_show_details = x.flag("enableDrill", true);
pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string();
if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() {
cx.warn(format!("PivotTable \"{}\": calculated items and label/value filters are not supported", pt.name));
+4
View File
@@ -120,6 +120,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
if let Some(t) = e.child("tabColor") {
sheet.tab_color = Some(read_color(t, &cx.palette));
}
if let Some(o) = e.child("outlinePr") {
sheet.summary_below = o.flag("summaryBelow", true);
sheet.summary_right = o.flag("summaryRight", true);
}
fit_to_page = e.child("pageSetUpPr").is_some_and(|p| p.flag("fitToPage", false));
}
"sheetViews" => read_views(e, &mut sheet),
+11 -1
View File
@@ -138,11 +138,21 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let mut s = format!("{XML_DECL}<worksheet xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
// sheetPr
let fit = sheet.print.fit_to.is_some();
if sheet.tab_color.is_some() || fit {
let summary = !sheet.summary_below || !sheet.summary_right;
if sheet.tab_color.is_some() || fit || summary {
s.push_str("<sheetPr>");
if let Some(c) = &sheet.tab_color {
let _ = write!(s, "<tabColor {}/>", color_attrs(c));
}
if summary {
s.push_str("<outlinePr");
for (key, on) in [("summaryBelow", sheet.summary_below), ("summaryRight", sheet.summary_right)] {
if !on {
let _ = write!(s, " {key}=\"0\"");
}
}
s.push_str("/>");
}
if fit {
s.push_str("<pageSetUpPr fitToPage=\"1\"/>");
}
+28
View File
@@ -343,3 +343,31 @@ fn outline_symbols_flag_round_trips() {
let (wb, _) = read_xlsx(&minimal("<sheetData/>", &[], "", "")).unwrap();
assert!(wb.sheet(0).unwrap().show_outline_symbols);
}
/// Outline Settings (`outlinePr summaryBelow="0" summaryRight="0"`) are read and written back,
/// between the tab color and the page setup properties.
#[test]
fn outline_summary_directions_round_trip() {
let body = r#"<sheetPr><tabColor rgb="FF00B050"/><outlinePr summaryBelow="0"/><pageSetUpPr fitToPage="1"/></sheetPr><sheetData/><pageSetup fitToWidth="1" fitToHeight="1"/>"#;
let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap();
let sh = wb.sheet(0).unwrap();
assert!(!sh.summary_below && sh.summary_right);
let bytes = write_xlsx(&wb).unwrap();
let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
let (tab, pr, fit) = (xml.find("<tabColor"), xml.find("<outlinePr summaryBelow=\"0\"/>"), xml.find("<pageSetUpPr"));
assert!(tab < pr && pr < fit && tab.is_some(), "{xml}");
let (back, _) = read_xlsx(&bytes).unwrap();
assert!(!back.sheet(0).unwrap().summary_below && back.sheet(0).unwrap().summary_right);
let body = r#"<sheetPr><outlinePr summaryRight="0"/></sheetPr><sheetData/>"#;
let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap();
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
let sh = back.sheet(0).unwrap();
assert!(sh.summary_below && !sh.summary_right);
// Absent means below and right, and the defaults write no `outlinePr`.
let (wb, _) = read_xlsx(&minimal("<sheetData/>", &[], "", "")).unwrap();
let sh = wb.sheet(0).unwrap();
assert!(sh.summary_below && sh.summary_right);
let bytes = write_xlsx(&wb).unwrap();
let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
assert!(!xml.contains("outlinePr"), "{xml}");
}
+22 -1
View File
@@ -80,7 +80,8 @@ fn workbook() -> Workbook {
wb.names.push(DefinedName { name: "SalesData".into(), scope: None, formula: "Data!$A$1:$F$9".into(), comment: String::new(), hidden: false });
let mut report = Sheet::new("Report");
// Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item.
// Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item, Enable
// show details off.
let p1 = PivotTable {
id: 1,
name: "Sales by Region".into(),
@@ -106,6 +107,7 @@ fn workbook() -> Workbook {
],
filters: vec![PivotFilter { source_col: "Channel".into(), selected: Some(vec!["Online".into()]) }],
layout: PivotLayout::Tabular,
enable_show_details: false,
grand_totals_rows: false,
grand_totals_cols: true,
subtotals: false,
@@ -165,6 +167,7 @@ fn workbook() -> Workbook {
PivotFilter { source_col: "Product".into(), selected: Some(vec!["A".into(), "B".into()]) },
],
layout: PivotLayout::Outline,
enable_show_details: true,
grand_totals_rows: true,
grand_totals_cols: false,
subtotals: true,
@@ -663,6 +666,24 @@ fn grand_totals_read_in_excel_direction() {
}
}
// `enableDrill="0"` is PivotTable Options › Data › Enable show details turned off; without the
// attribute the option is on.
#[test]
fn enable_show_details_reads_and_writes_enable_drill() {
let table = |attr: &str| {
format!(
r#"<pivotTableDefinition name="Drill" cacheId="1" {attr}><location ref="C1:F4"/><pivotFields><pivotField axis="axisRow"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><rowFields><field x="0"/></rowFields><dataFields><dataField name="Sum of V" fld="1"/></dataFields></pivotTableDefinition>"#
)
};
for (attr, on) in [("enableDrill=\"0\"", false), ("enableDrill=\"1\"", true), ("", true)] {
let (wb, _) = read_xlsx(&with_pivot(&table(attr), Some(GOOD_CACHE))).unwrap();
assert_eq!(wb.sheet(0).unwrap().pivots[0].enable_show_details, on, "{attr}");
let parts = unzip(&write_xlsx(&wb).unwrap());
let written = String::from_utf8(parts.get("xl/pivotTables/pivotTable1.xml").unwrap().clone()).unwrap();
assert_eq!(written.contains(" enableDrill=\"0\""), !on, "{attr}: {written}");
}
}
#[test]
fn data_on_rows_with_one_value_field_reads_without_warning() {
let table = r#"<pivotTableDefinition name="OneValue" cacheId="1" dataOnRows="1"><location ref="C1:D4"/><pivotFields><pivotField axis="axisRow"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><rowFields><field x="0"/></rowFields><dataFields><dataField name="Total" fld="1"/></dataFields></pivotTableDefinition>"#;
+1
View File
@@ -429,6 +429,7 @@ fn sample() -> Workbook {
ChartKind::Doughnut,
ChartKind::Area,
ChartKind::AreaStacked,
ChartKind::AreaStacked100,
ChartKind::Scatter,
ChartKind::ScatterLines,
ChartKind::Bubble,
+34 -1
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-10 · ** Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
@@ -49,6 +49,39 @@ including tests, 2026-10-10.
5. **Files**: `engine/io.rs` sniffs content (XLSX, XLSB, ODS) before trusting the extension and
dispatches to `crates/xlsx`; imports that lose data return warnings shown to the user.
## UI building blocks
The panels in `crates/ui-egui` share a few pieces, so a new feature reuses them instead of
drawing its own:
- **Colours**: `theme::Tokens`, each with a light and a dark value. The sheet stays white in both
themes, so colours drawn on it (`sheet_ref_colors`, `sheet_control_*`, icons through
`icons::paint_on_sheet`) keep their light values. `theme::contrast_tests` holds every
foreground/background pair to WCAG 2 (4.5:1 for text, 3:1 for glyphs) in both themes; a new
token pair is added there. Icons (`icons.rs`, drawn in code) take their paper and category
colours (`theme::Cat`) from the tokens.
- **Ribbon widgets**: `widgets.rs` has the buttons every tab is built from (`big_button`,
`small_button`, `split_button`, `big_split_button`, `toggle_button`), translated through
`i18n` in one place. Split buttons return the main and arrow responses separately, with
accessible names (`AutoSum`, `AutoSum options`). The ribbon card scrolls a tab wider than the
window, with arrows at its edges; `ribbon::RibbonOverflow` records the groups' size each frame,
and a test keeps every tab within the card's three rows.
- **Code editor**: `widgets::code_editor(ui, id, &mut text, Lang, max_lines)` (`code_editor.rs`)
edits code in the monospace font with `Tokens::syntax` colours: Python (keywords, strings
including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts,
comments). Tab indents (every selected line, Shift+Tab dedents), Enter keeps the line's
indentation (one level more after a Python colon), both applied in input order with the typing
around them: a pass can carry several events, and the editor's keys that follow an event the
text field acts on (select all, an arrow) wait for the editor's next pass, which puts them
ahead of its input, so the field never gets a Tab or an Enter of a focused editor; and it
scrolls past `max_lines` with the caret in view. The tokenizer and the key edits are pure functions with tests, including
hostile input.
- **Formula bar**: grows with its content from one line to `formula_bar::MAX_LINES` (five), then
scrolls; one line is Excel's 30 px unless the font's rows need more (`formula_bar::one_line`;
the Windows UI font's row is 17.28 px), so every counted line fits whatever the font;
`formula_bar::BarLayout` records where its parts went each frame, for tests.
## Engine building blocks for multi-command actions
Actions made of many commands (macros, a debugger that runs one step per call) use these parts of
+4 -3
View File
@@ -1,6 +1,6 @@
# Chart parity
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (stale chart notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip; PRs #158 and #161 merged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's chart types and chart elements against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
@@ -17,7 +17,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
| Bar | clustered, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D |
| Line | line, stacked, 100% stacked, with markers ×3, 3-D line | 3 of 7 (line, with markers, stacked) | no 100% stacked; smoothed lines landed (#62) |
| Pie | pie, 3-D pie, pie of pie, bar of pie, doughnut | 2 of 5 (pie, doughnut) | no exploded, pie of pie, bar of pie |
| Area | area, stacked, 100%, 3-D ×3 | 2 of 6 | no 100%, no 3-D |
| Area | area, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D; 100% stacked drawn as shares of each category's total and kept as `percentStacked` in XLSX |
| X Y (scatter) | markers, smooth lines (±markers), straight lines (±markers) | 2 of 5 | no smooth lines |
| Bubble | bubble, 3-D bubble | 1 of 2 | |
| Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | |
@@ -31,7 +31,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
| Waterfall, Funnel | 2 | 2 of 2 | |
| Filled map | 1 | 0 | needs geographic boundary data (licensing decision) |
| Combo | custom combinations, secondary axis | yes (line on secondary axis) | |
| **Total** | ~60 subtypes | **25 (~42%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate |
| **Total** | ~60 subtypes | **26 (~43%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate |
## Chart elements and formatting
@@ -90,6 +90,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` |
| 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) |
| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) |
| 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) |
+6 -5
View File
@@ -1,15 +1,16 @@
# GridCraft — egui 0.37 migration plan
Status (2026-10-24): the local migration incorporates frozen main
`3099df934eda27cd457a9b8d1525d3fc973a1b7b`. Canonical pins are egui
Status (2026-10-25): the local migration incorporates main
`52fa4406c1ebff8b260a2922472f03aba198b9cf`. Canonical pins are egui
`6d518fc799681571963842f95b55a6b68431b15a`, winit
`a517b3846ee8457d84ee4975110bfda6797c44dc`, craft-ui/craft-print
`25da1314d443171b4adeaad2cd9454b273f35c6f` and craft-fonts
`b076417b5880bd4154bd22f00695b1d28c6bea83`. The regenerated lock SHA256 is
`426a766291a8246573bf57e3ab3f5f17ff812487582254a32ff5a2ec0db39f27` and passed the dependency-source audit.
`egui037-gridcraft-final-ci-20261024-2038` passed all ten stages: i18n-report,
`egui037-grid-final-main-ci-20261025-0854` passed all ten stages: i18n-report,
fmt, strict workspace all-target clippy, workspace tests, doctest, assets, layers,
WASM, parity and keytips. Actual totals are 2,351 passed, none failed and one ignored.
WASM, parity and keytips. Actual totals are 2,463 passed, none failed and one ignored across 35 summaries.
Independent review verified the merged main behavior and the final scroll/accessibility fixes.
The local merge preserves both migration history and the reviewed upstream changes;
final code and dependency bytes match the passing CI. Subsequent status edits are prose only.
Native/platform/performance and application E21b qualification remain open; canonical
@@ -17,7 +18,7 @@ pins and full CI do not imply that every native contract is qualified.
Human review and explicit publication approval are required. Earlier compiler logs,
source anchors, baseline inventories and proposed steps below are historical records;
this status governs current pins and acceptance. Final commit/source/CI bindings are in
`final-landing/local-review-commits-2213/gridcraft` in the migration task evidence.
`final-landing/grid-publication0865` in the migration task evidence.
Scope: `storytold/gridcraft` only. The shared `storytold/craft-libs`, egui fork and winit fork are
dependencies of this plan, not files this plan changes. Application document models, commands, undo
+5 -2
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -85,7 +85,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
`insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields
and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists
its source records on a new sheet; the "Enable show details" option isn't modelled yet.
its source records on a new sheet. The "Enable show details" option (`pivot.options`
`enableShowDetails`, saved as `enableDrill`) turns it off; it has no PivotTable Options dialog
yet (command only).
- **Impact:** pivots are the analyst's main tool.
- **Estimate:** 20–30 h for beta (25–40 h full).
@@ -179,6 +181,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` |
| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) |
| 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step |
| 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite |
+33 -14
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -16,23 +16,25 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|---|---|---|---|
| 2026-10-14 | minor | Dialogs (Find and Replace, Go To, Format Cells, Sort and the rest) move by their title bar instead of staying pinned to the centre (#1250) |
| Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) |
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928) | done; missing contextual tabs for pictures, shapes, sparklines, slicers |
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928); large split buttons (Formulas › AutoSum); arrows at the card's edges scroll a tab wider than the window | done; missing contextual tabs for pictures, shapes, sparklines, slicers; groups do not collapse when narrow |
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) |
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers |
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing |
| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h |
| Formula bar with Name Box, expand, function hints | yes | yes | done |
| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle |
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
| Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership |
| Formulas › Error Checking arrow: Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking… and Circular References, which lists a cell on each circular reference as `Sheet!A1`, up to 100, and selects the one picked, on its own sheet (unavailable when there are none) | done; the Error Checking… item and the limit of 100 are GridCraft's own |
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) |
| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial |
| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes; a right-click on the status bar opens a menu that turns the statistics on and off (Average, Count, Numerical Count, Minimum, Maximum, Sum; `view.statusBar`) | done for the statistics; the other status bar options are not customizable |
| Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes; PivotTable source-field list scrollbar sits at the right edge of its pane | element-level Format pane for shapes and pictures missing |
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing |
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) |
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239); icons, reference colours, primary buttons and the comments pane follow the theme, and a unit test holds every token pair to WCAG 2 contrast in both themes |
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
| Dark mode, system appearance | yes | yes | done (10-09) |
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) |
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344) | margins drag on the rulers (#792, with header and footer margins); header/footer pictures and the Scale with Document / Align with Page Margins check boxes missing (XLSX keeps both settings) (1–3 h) |
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) |
| Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) |
## Grid interaction
@@ -50,7 +52,7 @@ this logic, with one undo step.
| Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) |
| Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented |
| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing |
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
| In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done |
| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. |
@@ -59,16 +61,16 @@ this logic, with one undo step.
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
| Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). |
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog |
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; Data › Outline › Settings (`data.outlineSettings`) puts summary rows above and summary columns left of their detail, saved as `outlinePr` (the dialog's Automatic styles check box and Apply Styles button are missing), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog |
| Reorder selected whole columns by dragging their headers | done (10-11) |
| Freeze panes, split panes | freeze done; split to verify |
| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) |
| Copy as HTML table for other apps | done (#36, 10-10) |
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
| Move and resize charts, pictures and shapes | charts can't be resized (#190) |
| Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial |
| Quick Analysis (Ctrl+Q) | missing |
| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); the tabs/scrollbar split can't be dragged yet |
| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |
| Text overflow into empty neighbours, wrap, rotation, shrink to fit | done |
## Keyboard
@@ -84,7 +86,7 @@ this logic, with one undo step.
| Shortcuts bound to commands | 200+ documented shortcuts per platform | audited below (#110): shortcuts whose feature exists are bound, with the exceptions listed; the rest wait for their features |
| Ctrl+arrow, Ctrl+Shift+arrow, Ctrl+Home/End, Ctrl+Shift+Home/End, Page Up/Down, Alt+Page | yes | done |
| Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done |
| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing |
| F2, F3, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F4 Repeat missing |
| Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) |
| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; each Borders letter (H, B, …) draws its own preset, H, B, M opens Format Cells on the Border tab, and Erase Border, Line Color and Line Style are reserved; QAT/Backstage excluded |
| Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) |
@@ -133,10 +135,13 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w
| Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** |
| F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** |
| Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** |
| F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) |
| F3 | not checked | Paste Name dialog, only when the workbook has names: OK puts the name at the caret of the formula being edited (or starts `=Name`), Paste List writes each name and what it refers to from the active cell (`formulas.pasteName`) | **added** |
| F1, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) |
| Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing |
| F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) |
| Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing |
| Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** |
| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing |
| Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** |
| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing |
| — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) |
@@ -152,6 +157,9 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing
Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its
title bar, keeping its place while it is open (#1250).
Go To lists the named ranges of every sheet (a name scoped to another sheet as `Sheet2!Name`) and
the references entered before; a double-click goes to one. Its Reference box takes several names
or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) and selects them all.
New Formatting Rule's formula box points like the formula bar: a click on the grid writes the
cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule
applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet.
@@ -160,7 +168,8 @@ applies to as it is (#2348). Other dialogs' reference fields don't take clicks y
AccessKit is enabled, but the grid, ribbon and dialogs are not exposed as an accessible tree with
cell addresses and values, so screen readers can't navigate a sheet; there is no high-contrast
theme or accessibility checker. Excel is strong here. 12–18 h.
theme or accessibility checker. Excel is strong here. 12–18 h. The light and dark themes' colour
pairs (text 4.5:1, glyphs 3:1) are checked by `theme::contrast_tests`.
## Feel
@@ -178,9 +187,17 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |
| 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` |
| 2026-10-24 | minor | Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar, as in Excel ("Expand or collapse the formula bar. Ctrl+Shift+U"), on the grid and in the cell editor or formula bar while editing. On Windows and Linux it used to underline the selection, as Ctrl+U does. The command is `view.expandFormulaBar` (`{on?}`, toggles without it) |
| 2026-10-24 | minor | Right-clicking a PivotTable's value, subtotal or grand total cell offers Show Details in the cell menu, as in Excel ("Right-click a field in the values area of the PivotTable, and then click Show Details"): it lists the cell's source records on a new sheet, as double-clicking the cell does (`pivot.showDetails`). Labels and headers of a current report don't offer it; in a report that is out of date every cell offers it, and choosing it asks for a refresh |
| 2026-10-24 | minor | Sheet tabs: the handle at the horizontal scrollbar's left edge shows the resize cursor, and dragging it moves the boundary, as in Excel ("Hover the mouse pointer at the edge of the scrollbar until you see the double-headed arrow"). Dragging it right gives the tabs more of the bar, until the scrollbar is 100 px wide (the scrollbar stays 100 px wide after the window is narrowed); dragging it left gives the scrollbar more, down to the New sheet button. Room the tabs don't fill stays empty, with New sheet after the last tab. Each open workbook keeps its own share; a workbook starts at the default (tabs up to 60%) |
| 2026-10-24 | minor | Outline Settings (Data ▸ Outline ▸ Settings, `data.outlineSettings`): clearing Summary rows below detail or Summary columns to right of detail puts the − / + buttons on the line above or left of each group, and Hide Detail, Show Detail and the level buttons mark that line collapsed. Changing a direction moves the marks of collapsed groups. Read and written as `outlinePr summaryBelow` / `summaryRight` |
| 2026-10-23 | minor | F3, Formulas › Use in Formula › Paste Names… and Alt+M, S, P open the Paste Name dialog (F3 only when the workbook has names). It lists the workbook's names and the active sheet's own in alphabetical order, hidden names left out; OK or a double-click puts the selected name at the caret of the formula being edited, in place of selected text, or starts `=Name` in the active cell (`formulas.pasteName` `{name}`), and Paste List writes each name and the text of what it refers to in two columns from the active cell (`{list: true}`). While a dialog is open over the edit, the cell editor takes no keys, and Cancel leaves the selected text selected. The Use in Formula menu lists the same names and inserts at the caret too (`formulas.useInFormula`); it used to list hidden names and other sheets' names, the menu added the name at the end of the text, and the command replaced the text being edited |
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |
| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |
| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` with `mode` set to `days`, `weekdays`, `months` or `years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped |
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |
| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") |
| 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel |
@@ -217,6 +234,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) |
| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) |
| 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers |
| 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off |
| 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) |
| 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area |
| 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) |
| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |