diff --git a/ROADMAP.md b/ROADMAP.md index 3674b593c..6c2ffe7e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells stored and kept in files; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells stored and kept in files; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,7 +135,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-11-01 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | +| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | +| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. | | 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). | | 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) | | 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | @@ -251,7 +252,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| -| 2026-11-01 | minor | Progress log: Python cells as data | +| 2026-11-02 | minor | Progress log: Python cells as data | +| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. | | 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) | | 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | | 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories | diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 6a64558a1..7e50e78f6 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -14,6 +14,7 @@ mod frame; mod kinds; mod legend; mod lines; +mod table; mod trend; use cartesian::{cartesian, scatter}; diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index d449498e7..ce0bbebb0 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -208,7 +208,9 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx // Outer levels padded to the slots (forecast slots stay in the last group). let outer: Vec> = data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); + let (area, table) = super::table::reserve(ctx, chart, data, area, ncat_slots); + let category_axis = super::table::category_axis(&table, &labels, &outer); + let plot = frame(ctx, area, category_axis, &y1, y2.as_ref(), horizontal, chart.gridlines); if !plot.ok() { return; } @@ -355,6 +357,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); } draw_trend_labels(ctx, plot, &trend_labels); + super::table::draw(ctx, chart, data, plot, table.as_ref()); } // ---------------------------------------------------------------- scatter & bubble diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs index 0117423b3..92d8dd9e3 100644 --- a/crates/chart/src/render/frame.rs +++ b/crates/chart/src/render/frame.rs @@ -74,12 +74,14 @@ pub(super) enum CatAxis<'a> { /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; /// one entry per category, non-empty where a group starts). Labels(&'a [String], &'a [Vec]), + /// A data table supplies the labels below a vertical chart. Its stub must clear the plot. + Table(&'a [String], f32), Numeric(Scale, &'a Fmt), } /// Groups of an outer category level: `(first, end)` index pairs, a group starting at every /// non-empty entry (and at the first category). -fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { +pub(super) fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); starts.push(n); starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() @@ -101,9 +103,13 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let ls = LABEL_SIZE; let lh = ls * 1.4; let ylabels = y.labels(); + let stub = match &cat { + CatAxis::Table(_, width) => *width, + _ => 0.0, + }; let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); if !horizontal { - let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); + let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4).max(stub); let y2labels = y2.map(ValAxis::labels); let right = match (&y2labels, &cat) { (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), @@ -112,7 +118,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti }; let levels = match &cat { CatAxis::Labels(_, o) => o.len() as f32, - CatAxis::Numeric(..) => 0.0, + CatAxis::Numeric(..) | CatAxis::Table(..) => 0.0, }; let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; if !plot.ok() { @@ -135,6 +141,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); match cat { + CatAxis::Table(..) => {} CatAxis::Labels(l, outer) => { let n = l.len().max(1); let cw = plot.w / n as f32; @@ -181,6 +188,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti } else { let (labels, outer): (Vec, &[Vec]) = match cat { CatAxis::Labels(l, o) => (l.to_vec(), o), + CatAxis::Table(l, _) => (l.to_vec(), &[]), CatAxis::Numeric(..) => (vec![], &[]), }; let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); @@ -189,7 +197,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); let levels_w: f32 = level_w.iter().sum(); let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; - let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; + let left = (lw + levels_w).max(stub); + let plot = Bx { x: area.x + left, y: area.y + 2.0, w: area.w - left - right, h: area.h - lh - 6.0 }; if !plot.ok() { return plot; } diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs index fec1e4549..5ddb5a3cd 100644 --- a/crates/chart/src/render/legend.rs +++ b/crates/chart/src/render/legend.rs @@ -85,7 +85,7 @@ fn swatch_w(sw: Swatch, s: f32) -> f32 { if sw == Swatch::Box { s } else { s * 2.0 } } -fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { +pub(super) fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { match sw { Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), diff --git a/crates/chart/src/render/table.rs b/crates/chart/src/render/table.rs new file mode 100644 index 000000000..f0839dcfe --- /dev/null +++ b/crates/chart/src/render/table.rs @@ -0,0 +1,107 @@ +//! The chart data table uses the same primitives as the plot, including in print/PDF output. + +use super::frame::{Bx, CatAxis, level_groups}; +use super::legend::{Swatch, cat_label, draw_swatch, plot_kind}; +use super::{AXIS, Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, TEXT, VAlign}; + +pub(super) struct Layout { + top: f32, + row_height: f32, + headers: usize, + stub: f32, + columns: usize, +} + +pub(super) fn category_axis<'a>(layout: &Option, labels: &'a [String], outer: &'a [Vec]) -> CatAxis<'a> { + match layout { + Some(t) => CatAxis::Table(labels, t.stub), + None => CatAxis::Labels(labels, outer), + } +} + +/// Reserve at most half the available height. A table that cannot fit is explicitly labelled +/// instead of dropping rows or allocating a series × point-sized matrix. +pub(super) fn reserve(ctx: &mut Ctx, chart: &Chart, data: &ChartData, mut area: Bx, columns: usize) -> (Bx, Option) { + if chart.data_table.is_none() || !chart.supports_data_table() { + return (area, None); + } + let headers = data.category_levels.len() + 1; + let rows = headers + data.series.len(); + let row_height = LABEL_SIZE * 1.4 + 4.0; + let height = row_height * rows as f32; + let key_width = if chart.data_table.as_ref().is_some_and(|t| t.show_keys) { 22.0 } else { 0.0 }; + let names = data.series.iter().take(32).map(|s| ctx.tw(&s.name, LABEL_SIZE)).fold(0.0f32, f32::max); + let stub = (names + key_width + 10.0).clamp(24.0, (area.w * 0.3).max(24.0)); + if columns == 0 || columns > 128 || data.series.len() > 32 || height > area.h * 0.5 || (area.w * 0.6) / (columns as f32) < 18.0 { + let message = ctx.fit("Data table needs more space", LABEL_SIZE, area.w); + ctx.text(area.cx(), area.bottom(), &message, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + area.h -= row_height; + return (area, None); + } + area.h -= height; + (area, Some(Layout { top: area.bottom(), row_height, headers, stub, columns })) +} + +pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, layout: Option<&Layout>) { + let Some(layout) = layout else { return }; + let Some(settings) = &chart.data_table else { return }; + let x0 = plot.x - layout.stub; + let top = layout.top; + let rows = layout.headers + data.series.len(); + let bottom = top + layout.row_height * rows as f32; + let cw = plot.w / layout.columns as f32; + // Outermost category level first, then the individual categories. + for (i, level) in data.category_levels.iter().rev().enumerate() { + for (first, end) in level_groups(level, layout.columns) { + let text = level.get(first).map(String::as_str).unwrap_or(""); + let text = ctx.fit(text, LABEL_SIZE, (end - first) as f32 * cw - 4.0); + let x = plot.x + (first + end) as f32 * cw / 2.0; + ctx.text(x, top + (i as f32 + 0.5) * layout.row_height, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle)); + } + } + let header_y = top + (layout.headers as f32 - 0.5) * layout.row_height; + let n = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + for j in 0..layout.columns { + let text = if j < n { cat_label(data, j) } else { String::new() }; + let text = ctx.fit(&text, LABEL_SIZE, cw - 4.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, header_y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle)); + } + for (i, series) in data.series.iter().enumerate() { + let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height; + let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 }; + if settings.show_keys { + let swatch = match plot_kind(chart, series) { + ChartKind::Line | ChartKind::LineStacked => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + _ => Swatch::Box, + }; + draw_swatch(ctx, swatch, x0 + 4.0, y, 7.0, series.color); + } + let name = ctx.fit(&series.name, LABEL_SIZE, plot.x - name_x - 4.0); + ctx.text(name_x, y, &name, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle)); + let fmt = Fmt::new(&series.number_format); + for j in 0..layout.columns { + let value = series.values.get(j).copied().flatten().map(|v| fmt.format(v, None)).unwrap_or_default(); + let text = ctx.fit(&value, LABEL_SIZE, cw - 4.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle)); + } + } + if settings.horizontal_border { + for row in 1..rows { + ctx.hline(x0, plot.right(), top + row as f32 * layout.row_height, AXIS, 0.75); + } + } + if settings.vertical_border { + ctx.vline(plot.x, top, bottom, AXIS, 0.75); + // The outer headers span groups; category dividers begin at the innermost header. + for j in 1..layout.columns { + ctx.vline(plot.x + j as f32 * cw, top + (layout.headers - 1) as f32 * layout.row_height, bottom, AXIS, 0.75); + } + } + if settings.outline { + ctx.hline(x0, plot.right(), top, AXIS, 0.75); + ctx.hline(x0, plot.right(), bottom, AXIS, 0.75); + ctx.vline(x0, top, bottom, AXIS, 0.75); + ctx.vline(plot.right(), top, bottom, AXIS, 0.75); + } +} diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 3f1a068a2..46d047afc 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -5,6 +5,8 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl use crate::*; +mod data_table; + const ALL_KINDS: [ChartKind; 26] = [ ChartKind::ColumnClustered, ChartKind::ColumnStacked, @@ -43,6 +45,7 @@ fn chart(kind: ChartKind) -> Chart { series: vec![], legend: LegendPos::Bottom, data_labels: true, + data_table: None, gridlines: true, style: 0, x_title: Some("Quarter".into()), diff --git a/crates/chart/src/tests/data_table.rs b/crates/chart/src/tests/data_table.rs new file mode 100644 index 000000000..13e2baf6c --- /dev/null +++ b/crates/chart/src/tests/data_table.rs @@ -0,0 +1,121 @@ +use super::*; +use gridcraft_model::ChartDataTable; + +#[test] +fn tables_draw_source_values_for_every_supported_chart_kind() { + for kind in ALL_KINDS { + let mut c = chart(kind); + c.legend = LegendPos::None; + c.data_labels = false; + c.data_table = Some(ChartDataTable::default()); + let supported = c.supports_data_table(); + let d = sample(kind); + let prims = render(&c, &d, 900.0, 500.0, &ApproxMeasure); + assert_sane(&prims, 900.0, 500.0); + let labels = texts(&prims); + // Source amounts, including on 100% stacked charts, not shares or running totals. + if supported { + for value in ["$12,000", "-$4,000", "$38,000", "$61,870"] { + assert!(labels.contains(&value), "{kind:?}: missing {value}"); + } + } else { + c.data_table = None; + assert_eq!(prims, render(&c, &d, 900.0, 500.0, &ApproxMeasure)); + } + } +} + +#[test] +fn table_columns_align_with_plot_and_keep_missing_values_blank() { + let mut c = chart(ChartKind::ColumnClustered); + c.title = Some(String::new()); + c.x_title = None; + c.y_title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.data_table = Some(ChartDataTable::default()); + let color = [1, 2, 3, 255]; + let mut s = series("Rate", &[0.25, 0.0, 0.75], color, c.kind); + s.values[1] = None; + s.number_format = "0.0%".into(); + let d = ChartData { categories: ["Jan", "Feb", "Mar"].map(String::from).to_vec(), series: vec![s], ..Default::default() }; + let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure); + let at = |text: &str| -> (f32, f32) { + p.iter() + .find_map(|p| match p { + Prim::Text { text: t, x, y, .. } if t == text => Some((*x, *y)), + _ => None, + }) + .unwrap() + }; + let (x1, y1) = at("25.0%"); + let (x3, y3) = at("75.0%"); + let header_y = at("Jan").1; + assert!(y1 > header_y && (y1 - y3).abs() < 0.01); + assert_eq!(at("Jan").0, x1); + assert_eq!(at("Mar").0, x3); + assert_eq!(texts(&p).iter().filter(|t| **t == "Jan").count(), 1, "the table replaces category-axis labels"); + let centers: Vec = p + .iter() + .filter_map(|p| match p { + Prim::Rect { x, y, w, fill: Some(f), .. } if *f == color && *y < header_y => Some(x + w / 2.0), + _ => None, + }) + .collect(); + assert_eq!(centers.len(), 2); + assert!((centers[0] - x1).abs() < 0.01 && (centers[1] - x3).abs() < 0.01); + let cells: Vec<&str> = p + .iter() + .filter_map(|p| match p { + Prim::Text { y, text, .. } if (*y - y1).abs() < 0.01 => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(cells, ["Rate", "25.0%", "75.0%"], "blank cells are not zeroes"); + assert!( + p.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)), + "legend key independent of hidden chart legend" + ); + c.data_table = Some(ChartDataTable { show_keys: false, ..Default::default() }); + let off = render(&c, &d, 600.0, 400.0, &ApproxMeasure); + assert!(!off.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y))); +} + +#[test] +fn table_borders_are_independent_and_multilevel_headers_are_kept() { + let mut c = chart(ChartKind::Line); + c.legend = LegendPos::None; + c.data_labels = false; + let mut d = sample(c.kind); + d.category_levels = vec![vec!["2025".into(), "".into(), "".into(), "2026".into(), "".into(), "".into()]]; + for (h, v, outline, want) in [(false, false, false, 0), (true, false, false, 4), (false, true, false, 6), (false, false, true, 4)] { + c.data_table = Some(ChartDataTable { horizontal_border: h, vertical_border: v, outline, show_keys: false }); + let p = render(&c, &d, 900.0, 600.0, &ApproxMeasure); + let year_y = p + .iter() + .find_map(|p| match p { + Prim::Text { text, y, .. } if text == "2025" => Some(*y), + _ => None, + }) + .unwrap(); + assert!(texts(&p).contains(&"2026")); + let lines = p.iter().filter(|p| matches!(p, Prim::Line { pts, .. } if pts.iter().all(|p| p[1] > year_y - 9.0))).count(); + assert_eq!(lines, want, "{h} {v} {outline}"); + assert_sane(&p, 900.0, 600.0); + } +} + +#[test] +fn dense_and_tiny_tables_are_bounded_and_explicit() { + let mut c = chart(ChartKind::ColumnClustered); + c.data_table = Some(ChartDataTable::default()); + let mut d = sample(c.kind); + d.categories = (0..20_000).map(|i| i.to_string()).collect(); + let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure); + assert!(texts(&p).contains(&"Data table needs more space")); + assert_sane(&p, 600.0, 400.0); + d.categories.clear(); + for (w, h) in [(1.0, 1.0), (10.0, 10.0), (40.0, 35.0), (200.0, 100.0)] { + assert_sane(&render(&c, &d, w, h, &ApproxMeasure), w, h); + } +} diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index 35e8426ee..134294542 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -27,6 +27,14 @@ const MAX_MATRIX_VARS: usize = 256; const MAX_REGRESSORS: usize = 16; /// Most histogram bins. const MAX_BINS: usize = 100_000; +/// Excel's limit on Fourier Analysis input values. +const MAX_FOURIER: usize = 4096; +/// Most numbers Random Number Generation or Sampling writes in one run. +const MAX_RANDOM: usize = 1_000_000; +/// Most trials of a binomial draw, and the farthest Poisson outcome considered. +const MAX_OUTCOMES: f64 = 1_000_000.0; +/// Largest seed: seeds are whole numbers that a JSON number holds exactly. +const MAX_SEED: u64 = (1 << 53) - 1; /// Part of the error a programmatic call gets when the output range already holds data; the /// dialog recognises it and asks before running again with `overwrite: true`. @@ -35,18 +43,24 @@ pub const OVERWRITE_ERROR: &str = "already holds data"; /// (id, label) of every tool, in the order of Excel's Analysis Tools list. pub const TOOLS: &[(&str, &str)] = &[ ("anovaSingle", "Anova: Single Factor"), + ("anovaTwoFactorWith", "Anova: Two-Factor With Replication"), + ("anovaTwoFactorWithout", "Anova: Two-Factor Without Replication"), ("correlation", "Correlation"), ("covariance", "Covariance"), ("descriptive", "Descriptive Statistics"), ("exponentialSmoothing", "Exponential Smoothing"), ("fTest", "F-Test Two-Sample for Variances"), + ("fourierAnalysis", "Fourier Analysis"), ("histogram", "Histogram"), ("movingAverage", "Moving Average"), + ("randomNumberGeneration", "Random Number Generation"), ("rankPercentile", "Rank and Percentile"), ("regression", "Regression"), + ("sampling", "Sampling"), ("tTestPaired", "t-Test: Paired Two Sample for Means"), ("tTestEqual", "t-Test: Two-Sample Assuming Equal Variances"), ("tTestUnequal", "t-Test: Two-Sample Assuming Unequal Variances"), + ("zTest", "z-Test: Two Sample for Means"), ]; pub fn specs() -> Vec { @@ -55,7 +69,7 @@ pub fn specs() -> Vec { "Data Analysis…", ["Data", "Analysis"], None, - "{tool: anovaSingle|correlation|covariance|descriptive|exponentialSmoothing|fTest|histogram|movingAverage|rankPercentile|regression|tTestPaired|tTestEqual|tTestUnequal (or the tool's label), output?: \"E1\" (top-left of the report; default a new sheet), newSheet?: name, outputTo?: range|newSheet (picks between the two), overwrite?: bool (else non-empty output cells are refused), labels?: bool (first row/column holds names), groupedBy?: columns|rows, …tool params} — range tools take `range`; descriptive: summary? (default true), confidence?: 95, kthLargest?, kthSmallest?; histogram: bins?: range (default evenly spaced), pareto?, cumulative?; regression: inputY, inputX, constantZero?, confidence?: 95, residuals?, standardizedResiduals?, normalProbability?; movingAverage: interval? (3), standardErrors?; exponentialSmoothing: damping? (0.3), standardErrors?; t-tests and fTest: range1, range2, alpha? (0.05), hypothesizedDifference? (0); anovaSingle: alpha?. Writes static values. No `tool` → {tools} (the menu opens the dialog).", + "{tool: anovaSingle|anovaTwoFactorWith|anovaTwoFactorWithout|correlation|covariance|descriptive|exponentialSmoothing|fTest|fourierAnalysis|histogram|movingAverage|randomNumberGeneration|rankPercentile|regression|sampling|tTestPaired|tTestEqual|tTestUnequal|zTest (or the tool's label), output?: \"E1\" (top-left of the report; default a new sheet), newSheet?: name, outputTo?: range|newSheet (picks between the two), overwrite?: bool (else non-empty output cells are refused), labels?: bool (first row/column holds names), groupedBy?: columns|rows, …tool params} — range tools take `range`; descriptive: summary? (default true), confidence?: 95, kthLargest?, kthSmallest?; histogram: bins?: range (default evenly spaced), pareto?, cumulative?; regression: inputY, inputX, constantZero?, confidence?: 95, residuals?, standardizedResiduals?, normalProbability?; movingAverage: interval? (3), standardErrors?; exponentialSmoothing: damping? (0.3), standardErrors?; t-tests and fTest: range1, range2, alpha? (0.05), hypothesizedDifference? (0); anovaSingle: alpha?; anovaTwoFactorWithout: range, labels? (the first row holds column names and the first column row names), alpha?; anovaTwoFactorWith: range (first row and column hold labels), rowsPerSample, alpha?; zTest: range1, range2, variance1, variance2 (known variances), alpha?, hypothesizedDifference?; fourierAnalysis: range (one row or column of 2^k numbers or complex text, at most 4096), inverse?; randomNumberGeneration: variables? (1), count (per variable; not for patterned), distribution: uniform{from, to} | normal{mean, standardDeviation} | bernoulli{p} | binomial{p, trials} | poisson{lambda} | patterned{from, to, step, repeatEach, repeatSequence} | discrete{valueRange: two columns of value and probability}, seed? (whole number, not for patterned; the same seed repeats the numbers; the result reports the seed used), at most 1,000,000 numbers; sampling: range, method?: periodic|random, period (periodic), samples (random, with replacement), seed?. Writes static values. No `tool` → {tools} (the menu opens the dialog).", has_doc, run )] @@ -107,7 +121,7 @@ fn confidence(p: &Json, key: &str) -> Result> { } fn alpha(p: &Json) -> Result { - match num(p, "alpha") { + match opt_num(p, "alpha")? { None => Ok(0.05), Some(a) if a > 0.0 && a < 1.0 => Ok(a), Some(_) => Err(bad(CMD, "alpha must be between 0 and 1")), @@ -1050,6 +1064,684 @@ fn anova_single(s: &Session, p: &Json) -> Result { Ok(g) } +// ---------------------------------------------------------------- two-factor anova and z-test + +/// Count, Sum, Average and Variance of a group, as the Anova summaries list them. +fn group_summary(x: &[f64]) -> [Value; 4] { + [Value::number(x.len() as f64), Value::number(x.iter().sum()), n_or_div0(mean(x)), n_or_div0(var_s(x))] +} + +const ANOVA_HEAD: [&str; 7] = ["Source of Variation", "SS", "df", "MS", "F", "P-value", "F crit"]; + +/// One line of an Anova table. A factor line, tested against the error term `err` (its MS and +/// df), adds F, the P-value and the critical F. +fn anova_line(g: &mut Grid, row: u32, label: &str, ss: f64, df: f64, err: Option<(f64, f64)>, alpha: f64) { + g.txt(row, 0, label); + g.num(row, 1, ss); + g.num(row, 2, df); + g.put(row, 3, ratio(ss, df)); + if let Some((mse, dfe)) = err { + let f = ss / df / mse; + g.put(row, 4, n_or_div0(f)); + g.put(row, 5, if f.is_finite() && dfe > 0.0 { callf("F.DIST.RT", &[f, df, dfe]) } else { div0() }); + g.put(row, 6, if df > 0.0 && dfe > 0.0 { callf("F.INV.RT", &[alpha, df, dfe]) } else { div0() }); + } +} + +/// The cells of a two-factor input range, row by row. +fn input_rows(s: &Session, p: &Json) -> Result>> { + let (si, r) = input_range(s, p, &["range", "input"])?; + Ok(variables(s, si, r, true, false, &|_| String::new())?.into_iter().map(|v| v.vals).collect()) +} + +/// The values of column `j` of a table of numbers. +fn column(data: &[Vec], j: usize) -> Vec { + data.iter().filter_map(|r| r.get(j).copied()).collect() +} + +/// Anova: Two-Factor Without Replication: one observation for each pair of levels (rows × +/// columns), the error term being the residual after both main effects. +fn anova_two_factor_without(s: &Session, p: &Json) -> Result { + let alpha = alpha(p)?; + let labels = flag(p, "labels"); + let mut rows = input_rows(s, p)?; + let head = if labels && !rows.is_empty() { Some(rows.remove(0)) } else { None }; + let mut row_names = Vec::with_capacity(rows.len()); + let mut data: Vec> = Vec::with_capacity(rows.len()); + for (i, mut row) in rows.into_iter().enumerate() { + let name = if labels && !row.is_empty() { row.remove(0).display() } else { format!("Row {}", i + 1) }; + row_names.push(name); + data.push(numbers(&row, false)?); + } + let (a, b) = (data.len(), data.first().map_or(0, Vec::len)); + if a < 2 || b < 2 { + return Err(EngineError::Other("Anova needs at least two rows and two columns of numbers.".into())); + } + let col_names: Vec = match head { + Some(h) => h.iter().skip(1).map(Value::display).collect(), + None => (1..=b).map(|j| format!("Column {j}")).collect(), + }; + let cols: Vec> = (0..b).map(|j| column(&data, j)).collect(); + let all: Vec = data.iter().flatten().copied().collect(); + let grand = mean(&all); + let row_means: Vec = data.iter().map(|r| mean(r)).collect(); + let col_means: Vec = cols.iter().map(|c| mean(c)).collect(); + let ss_rows: f64 = row_means.iter().map(|m| b as f64 * (m - grand).powi(2)).sum(); + let ss_cols: f64 = col_means.iter().map(|m| a as f64 * (m - grand).powi(2)).sum(); + // The residual of each observation after removing both main effects. + let ss_err: f64 = + data.iter().zip(&row_means).flat_map(|(r, rm)| r.iter().zip(&col_means).map(move |(x, cm)| (x - rm - cm + grand).powi(2))).sum(); + let (df_rows, df_cols) = ((a - 1) as f64, (b - 1) as f64); + let df_err = df_rows * df_cols; + let mut g = Grid::default(); + g.txt(0, 0, "Anova: Two-Factor Without Replication"); + g.labels(2, 0, &["SUMMARY", "Count", "Sum", "Average", "Variance"]); + let summary = |g: &mut Grid, row: u32, name: &str, x: &[f64]| { + g.txt(row, 0, name); + for (k, v) in group_summary(x).into_iter().enumerate() { + g.put(row, 1 + idx(k), v); + } + }; + for (i, (name, x)) in row_names.iter().zip(&data).enumerate() { + summary(&mut g, 3 + idx(i), name, x); + } + let first_col = 3 + idx(a) + 1; + for (j, (name, x)) in col_names.iter().zip(&cols).enumerate() { + summary(&mut g, first_col + idx(j), name, x); + } + let row = first_col + idx(b) + 2; + g.txt(row, 0, "ANOVA"); + g.labels(row + 1, 0, &ANOVA_HEAD); + let err = Some((ss_err / df_err, df_err)); + anova_line(&mut g, row + 2, "Rows", ss_rows, df_rows, err, alpha); + anova_line(&mut g, row + 3, "Columns", ss_cols, df_cols, err, alpha); + anova_line(&mut g, row + 4, "Error", ss_err, df_err, None, alpha); + g.txt(row + 6, 0, "Total"); + g.num(row + 6, 1, ss(&all)); + g.num(row + 6, 2, (all.len() - 1) as f64); + Ok(g) +} + +/// The number of rows in each sample of a two-factor Anova with replication. +fn rows_per_sample(p: &Json, data_rows: usize) -> Result { + let per = num(p, "rowsPerSample").ok_or_else(|| bad(CMD, "missing `rowsPerSample` (the number of rows in each sample)"))?; + if per < 2.0 || per.fract() != 0.0 || per > data_rows as f64 || !data_rows.is_multiple_of(per as usize) { + return Err(EngineError::Other("Rows per sample must be a whole number of at least 2 that divides the number of data rows.".into())); + } + Ok(per as usize) +} + +/// Anova: Two-Factor With Replication. The input range holds column labels in its first row and +/// sample labels in its first column; each sample (a level of the row factor) spans +/// `rowsPerSample` consecutive rows, so every cell of the design holds that many observations. +fn anova_two_factor_with(s: &Session, p: &Json) -> Result { + let alpha = alpha(p)?; + let mut rows = input_rows(s, p)?; + if rows.len() < 3 { + return Err(EngineError::Other("The input range must hold a row of labels and at least two samples.".into())); + } + let head = rows.remove(0); + let col_names: Vec = head.iter().skip(1).map(Value::display).collect(); + let per = rows_per_sample(p, rows.len())?; + let mut sample_names = Vec::new(); + let mut data: Vec> = Vec::with_capacity(rows.len()); + for (i, mut row) in rows.into_iter().enumerate() { + let name = if row.is_empty() { Value::Empty } else { row.remove(0) }; + if i.is_multiple_of(per) { + sample_names.push(name.display()); + } + data.push(numbers(&row, false)?); + } + let (a, b) = (sample_names.len(), col_names.len()); + if a < 2 || b < 2 { + return Err(EngineError::Other("Anova needs at least two samples and two columns of numbers.".into())); + } + // cells[i][j]: the observations of sample i in column j. + let cells: Vec>> = data.chunks(per).map(|block| (0..b).map(|j| column(block, j)).collect()).collect(); + let all: Vec = data.iter().flatten().copied().collect(); + let grand = mean(&all); + let sample_all: Vec> = data.chunks(per).map(|block| block.iter().flatten().copied().collect()).collect(); + let cols: Vec> = (0..b).map(|j| column(&data, j)).collect(); + let (sm, cm): (Vec, Vec) = (sample_all.iter().map(|x| mean(x)).collect(), cols.iter().map(|x| mean(x)).collect()); + let n = per as f64; + let ss_sample: f64 = sm.iter().map(|m| n * b as f64 * (m - grand).powi(2)).sum(); + let ss_cols: f64 = cm.iter().map(|m| n * a as f64 * (m - grand).powi(2)).sum(); + let mut ss_inter = 0.0; + let mut ss_within = 0.0; + for (i, row) in cells.iter().enumerate() { + for (j, cell) in row.iter().enumerate() { + let (si, cj) = (sm.get(i).copied().unwrap_or(f64::NAN), cm.get(j).copied().unwrap_or(f64::NAN)); + ss_inter += n * (mean(cell) - si - cj + grand).powi(2); + ss_within += ss(cell); + } + } + let (df_sample, df_cols) = ((a - 1) as f64, (b - 1) as f64); + let df_within = (a * b * (per - 1)) as f64; + let mut g = Grid::default(); + g.txt(0, 0, "Anova: Two-Factor With Replication"); + g.txt(2, 0, "SUMMARY"); + for (j, name) in col_names.iter().enumerate() { + g.txt(2, 1 + idx(j), name); + } + g.txt(2, 1 + idx(b), "Total"); + let stats = ["Count", "Sum", "Average", "Variance"]; + let mut row = 3u32; + let block = |g: &mut Grid, row: &mut u32, name: &str, groups: Vec<&[f64]>| { + g.txt(*row, 0, name); + let sums: Vec<[Value; 4]> = groups.iter().map(|x| group_summary(x)).collect(); + for (k, label) in stats.iter().enumerate() { + let r = *row + 1 + idx(k); + g.txt(r, 0, label); + for (j, sm) in sums.iter().enumerate() { + g.put(r, 1 + idx(j), sm.get(k).cloned().unwrap_or(Value::Empty)); + } + } + *row += 6; + }; + for ((name, row_cells), whole) in sample_names.iter().zip(&cells).zip(&sample_all) { + let mut groups: Vec<&[f64]> = row_cells.iter().map(Vec::as_slice).collect(); + groups.push(whole); + block(&mut g, &mut row, name, groups); + } + block(&mut g, &mut row, "Total", cols.iter().map(Vec::as_slice).collect()); + row += 1; + g.txt(row, 0, "ANOVA"); + g.labels(row + 1, 0, &ANOVA_HEAD); + let err = Some((ss_within / df_within, df_within)); + anova_line(&mut g, row + 2, "Sample", ss_sample, df_sample, err, alpha); + anova_line(&mut g, row + 3, "Columns", ss_cols, df_cols, err, alpha); + anova_line(&mut g, row + 4, "Interaction", ss_inter, df_sample * df_cols, err, alpha); + anova_line(&mut g, row + 5, "Within", ss_within, df_within, None, alpha); + g.txt(row + 7, 0, "Total"); + g.num(row + 7, 1, ss(&all)); + g.num(row + 7, 2, (all.len() - 1) as f64); + Ok(g) +} + +/// A known variance parameter: a positive number. +fn known_variance(p: &Json, key: &str) -> Result { + match num(p, key) { + Some(v) if v > 0.0 => Ok(v), + _ => Err(EngineError::Other(format!("Enter the known variance `{key}` (a positive number)."))), + } +} + +/// z-Test: Two Sample for Means, for two samples whose population variances are known. +fn z_test(s: &Session, p: &Json) -> Result { + let alpha = alpha(p)?; + let hmd = match opt_num(p, "hypothesizedDifference")? { + Some(d) => d, + None => num_or(p, "difference", 0.0)?, + }; + let (v1, v2) = (known_variance(p, "variance1")?, known_variance(p, "variance2")?); + let a = single(s, p, "range1", "Variable 1")?; + let b = single(s, p, "range2", "Variable 2")?; + let (x, y) = (numbers(&a.vals, true)?, numbers(&b.vals, true)?); + if x.is_empty() || y.is_empty() { + return Err(EngineError::Other("Each input range needs at least one number.".into())); + } + let (n1, n2) = (x.len() as f64, y.len() as f64); + let (m1, m2) = (mean(&x), mean(&y)); + let z = (m1 - m2 - hmd) / (v1 / n1 + v2 / n2).sqrt(); + let mut g = Grid::default(); + let row = test_head(&mut g, "z-Test: Two Sample for Means", &a.name, &b.name); + pair_row(&mut g, row, "Mean", Value::number(m1), Value::number(m2)); + pair_row(&mut g, row + 1, "Known Variance", Value::number(v1), Value::number(v2)); + pair_row(&mut g, row + 2, "Observations", Value::number(n1), Value::number(n2)); + pair_row(&mut g, row + 3, "Hypothesized Mean Difference", Value::number(hmd), Value::Empty); + pair_row(&mut g, row + 4, "z", n_or_div0(z), Value::Empty); + // The tail beyond |z|: 1 − Φ(|z|). + let tail = call("NORM.S.DIST", &[Value::Number(z.abs()), Value::Bool(true)]).as_f64().map(|c| 1.0 - c); + let lines: [(&str, Value); 4] = [ + ("P(Z<=z) one-tail", tail.map_or_else(div0, Value::number)), + ("z Critical one-tail", callf("NORM.S.INV", &[1.0 - alpha])), + ("P(Z<=z) two-tail", tail.map_or_else(div0, |t| Value::number(2.0 * t))), + ("z Critical two-tail", callf("NORM.S.INV", &[1.0 - alpha / 2.0])), + ]; + for (k, (label, v)) in lines.into_iter().enumerate() { + pair_row(&mut g, row + 5 + idx(k), label, v, Value::Empty); + } + Ok(g) +} + +// ---------------------------------------------------------------- fourier analysis + +/// Radix-2 fast Fourier transform (iterative Cooley–Tukey) in place, for a power-of-two number of +/// complex values (re, im). The forward transform is unscaled, X[k] = Σ x[n]·e^(−2πikn/N); the +/// inverse uses e^(+2πikn/N) and divides by N, so it returns the values the forward one took. +fn fft(a: &mut [(f64, f64)], inverse: bool) { + let n = a.len(); + if n < 2 || !n.is_power_of_two() { + return; + } + let bits = n.trailing_zeros(); + for i in 0..n { + let j = i.reverse_bits() >> (usize::BITS - bits); + if i < j { + a.swap(i, j); + } + } + let sign = if inverse { 1.0 } else { -1.0 }; + let mut len = 2; + while len <= n { + let ang = sign * 2.0 * std::f64::consts::PI / len as f64; + for start in (0..n).step_by(len) { + for k in 0..len / 2 { + // Each twiddle factor is computed directly, not by repeated multiplication. + let (wr, wi) = ((ang * k as f64).cos(), (ang * k as f64).sin()); + let (i, j) = (start + k, start + k + len / 2); + let (Some(&(ur, ui)), Some(&(xr, xi))) = (a.get(i), a.get(j)) else { continue }; + let (vr, vi) = (xr * wr - xi * wi, xr * wi + xi * wr); + if let Some(u) = a.get_mut(i) { + *u = (ur + vr, ui + vi); + } + if let Some(x) = a.get_mut(j) { + *x = (ur - vr, ui - vi); + } + } + } + len <<= 1; + } + if inverse { + let nf = n as f64; + a.iter_mut().for_each(|v| *v = (v.0 / nf, v.1 / nf)); + } +} + +/// Fourier Analysis: the discrete Fourier transform (or with `inverse` its inverse) of one row or +/// column of 2^k values, read and written as complex numbers the way IMREAL, IMAGINARY and +/// COMPLEX read and write them. +fn fourier_analysis(s: &Session, p: &Json) -> Result { + let v = single(s, p, "range", "")?; + let n = v.vals.len(); + if n == 0 || n > MAX_FOURIER || !n.is_power_of_two() { + return Err(EngineError::Other(format!("Fourier Analysis needs 1, 2, 4, 8 … {MAX_FOURIER} input values (a power of two)."))); + } + let mut a = Vec::with_capacity(n); + for x in &v.vals { + let part = |f: &str| call(f, std::slice::from_ref(x)).as_f64(); + match (x, part("IMREAL"), part("IMAGINARY")) { + (Value::Empty, ..) => return Err(non_numeric()), + (_, Some(re), Some(im)) => a.push((re, im)), + _ => return Err(EngineError::Other("Input range must hold numbers or complex numbers such as 3+4i.".into())), + } + } + let total: f64 = a.iter().map(|(re, im)| re.abs() + im.abs()).sum(); + let inverse = flag(p, "inverse"); + fft(&mut a, inverse); + // Parts that can only be rounding noise of the transform are written as 0. Each of the + // log2(N) butterfly stages adds at most a few units of f64::EPSILON times the magnitude of + // the values it combines, so the computed transform differs from the exact one by at most + // about log2(N) · ε · Σ|x_n| times a small constant (Higham, Accuracy and Stability of + // Numerical Algorithms, 2nd ed., chapter 24, The Fast Fourier Transform). The bound comes + // from the input and N, never from the largest output, so a small part beside a large one + // (an imaginary part of 1 beside a real part of 1e14, a harmonic of 4e-7 beside a DC term of + // 8e6) is kept. With N = 1 the transform is the identity and log2(N) = 0: nothing is zeroed. + // The inverse divides its outputs by N, so its noise is divided by N as well. An input sum + // that overflows gives no finite bound; then nothing is cleaned up. + let mut noise = 8.0 * f64::EPSILON * f64::from(n.trailing_zeros()) * total; + if inverse { + noise /= n as f64; + } + let noise = if noise.is_finite() { noise } else { 0.0 }; + let clean = |x: f64| if x.abs() <= noise { 0.0 } else { x }; + let mut g = Grid::default(); + let first = u32::from(flag(p, "labels")); + if first == 1 { + g.txt(0, 0, &v.name); + } + for (i, (re, im)) in a.into_iter().enumerate() { + g.put(first + idx(i), 0, callf("COMPLEX", &[clean(re), clean(im)])); + } + Ok(g) +} + +// ---------------------------------------------------------------- random numbers and sampling + +/// A seeded generator: xoshiro256** (Blackman and Vigna, a public-domain algorithm) with its +/// state filled by SplitMix64. The same seed gives the same numbers in GridCraft; they are not +/// Excel's numbers, whose generator is not documented. +struct Rng([u64; 4]); + +impl Rng { + fn new(seed: u64) -> Rng { + let mut x = seed; + let mut next = || { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + Rng([next(), next(), next(), next()]) + } + + fn next_u64(&mut self) -> u64 { + let [s0, s1, s2, s3] = self.0; + let out = s1.wrapping_mul(5).rotate_left(7).wrapping_mul(9); + let (s2, s3) = (s2 ^ s0, s3 ^ s1); + self.0 = [s0 ^ s3, s1 ^ s2, s2 ^ (s1 << 17), s3.rotate_left(45)]; + out + } + + /// Uniform in [0, 1), with 53 random bits. + fn uniform(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + /// Uniform whole number in [0, n), without modulo bias. + fn below(&mut self, n: u64) -> u64 { + if n <= 1 { + return 0; + } + let zone = u64::MAX - u64::MAX % n; + loop { + let v = self.next_u64(); + if v < zone { + return v % n; + } + } + } + + /// Standard normal by the Box–Muller transform. + fn normal(&mut self) -> f64 { + let (u1, u2) = (1.0 - self.uniform(), self.uniform()); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} + +/// The `seed` parameter, or a seed taken from the clock when there is none. +fn seed_of(p: &Json) -> Result { + let given = p.get("seed").is_some_and(|v| match v { + Json::Null => false, + Json::String(t) => !t.trim().is_empty(), + _ => true, + }); + if !given { + let ms = gridcraft_core::date::unix_now_ms().unwrap_or(0.0); + return Ok((ms * 1000.0) as u64 & MAX_SEED); + } + match num(p, "seed") { + Some(x) if x >= 0.0 && x.fract() == 0.0 && x <= MAX_SEED as f64 => Ok(x as u64), + _ => Err(EngineError::Other("The random seed must be a whole number from 0.".into())), + } +} + +/// A number parameter: `None` when absent, null or blank; an error when present but not a +/// finite number (a typed "0,3" or "abc" must not quietly become the default). +fn opt_num(p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::String(t)) if t.trim().is_empty() => Ok(None), + Some(_) => num(p, key).map(Some).ok_or_else(|| EngineError::Other(format!("`{key}` must be a number."))), + } +} + +/// A number parameter, `default` when absent or blank. +fn num_or(p: &Json, key: &str, default: f64) -> Result { + Ok(opt_num(p, key)?.unwrap_or(default)) +} + +/// A whole-number parameter of at least `min`, `default` when absent. +fn whole(p: &Json, key: &str, default: Option, min: f64) -> Result { + match opt_num(p, key)?.or(default) { + Some(x) if x >= min && x.fract() == 0.0 => Ok(x), + Some(_) => Err(EngineError::Other(format!("`{key}` must be a whole number of at least {min}."))), + None => Err(bad(CMD, format!("missing `{key}`"))), + } +} + +/// A probability parameter in [0, 1]. +fn probability(p: &Json) -> Result { + match num_or(p, "p", 0.5)? { + x if (0.0..=1.0).contains(&x) => Ok(x), + _ => Err(EngineError::Other("The p value must be between 0 and 1.".into())), + } +} + +/// The probabilities of a distribution on 0, 1, 2 … `last`, as (first outcome, probabilities of +/// first, first + 1, …), from its probability `at_mode` at `mode`, its most likely outcome, and +/// `ratio`(k) = pmf(k + 1) / pmf(k). Each term is one multiplication of its neighbour towards the +/// mode, so no worksheet function runs per outcome, and the terms only shrink away from the mode, +/// so nothing underflows before the terms that matter. The left tail ends where its terms vanish, +/// the right tail once the cumulative probability reaches 1 within f64 resolution (every later +/// draw would map to the last outcome). The table is as wide as the distribution's effective +/// support, not as `last`: Binomial(1,000,000, 0.5) takes about 23,000 entries, not 1,000,001. +fn pmf_table(last: f64, mode: f64, at_mode: f64, ratio: impl Fn(f64) -> f64) -> Result<(f64, Vec)> { + if !at_mode.is_finite() || at_mode <= 0.0 || !(0.0..=last).contains(&mode) || mode.fract() != 0.0 { + return Err(EngineError::Other("The distribution could not be tabulated.".into())); + } + let mut left = Vec::new(); + let (mut k, mut v) = (mode, at_mode); + while k > 0.0 { + v /= ratio(k - 1.0); + if v.is_nan() || v < f64::MIN_POSITIVE { + break; + } + left.push(v); + k -= 1.0; + } + let first = k; + let mut pmf: Vec = left.into_iter().rev().collect(); + let mut acc = pmf.iter().sum::() + at_mode; + pmf.push(at_mode); + let (mut k, mut v) = (mode, at_mode); + while k < last && acc < 1.0 - 1e-15 { + v *= ratio(k); + if v.is_nan() || v < f64::MIN_POSITIVE { + break; + } + pmf.push(v); + acc += v; + k += 1.0; + } + Ok((first, pmf)) +} + +/// A distribution on whole numbers tabulated for inversion sampling: the cumulative +/// probabilities of `first`, `first + 1`, … up to the last outcome that matters. +struct Table { + first: f64, + cdf: Vec, +} + +impl Table { + /// The distribution on 0, 1, 2 … `last` that `pmf_table` describes. + fn from_mode(last: f64, mode: f64, at_mode: f64, ratio: impl Fn(f64) -> f64) -> Result { + let (first, pmf) = pmf_table(last, mode, at_mode, ratio)?; + let mut acc = 0.0; + let cdf = pmf + .into_iter() + .map(|p| { + acc += p; + acc + }) + .collect(); + Ok(Table { first, cdf }) + } + + /// All the probability on one outcome. + fn point(value: f64) -> Table { + Table { first: value, cdf: vec![1.0] } + } + + /// A draw: the first outcome whose cumulative probability exceeds a uniform number (inversion). + fn draw(&self, r: &mut Rng) -> f64 { + let u = r.uniform() * self.cdf.last().copied().unwrap_or(1.0); + self.first + self.cdf.partition_point(|c| *c <= u).min(self.cdf.len().saturating_sub(1)) as f64 + } +} + +/// The numbers of a patterned sequence: `from` to `to` in steps, each number repeated, the whole +/// sequence repeated. +fn pattern(p: &Json) -> Result> { + let (from, to, step) = (num_or(p, "from", 1.0)?, num_or(p, "to", 1.0)?, num_or(p, "step", 1.0)?); + let (each, times) = (whole(p, "repeatEach", Some(1.0), 1.0)?, whole(p, "repeatSequence", Some(1.0), 1.0)?); + if step <= 0.0 || to < from { + return Err(EngineError::Other("The pattern must run from a smaller number to a larger one in positive steps.".into())); + } + let steps = ((to - from) / step + 1e-9).floor() + 1.0; + if steps * each * times > MAX_RANDOM as f64 { + return Err(EngineError::Other(format!("Random Number Generation writes at most {MAX_RANDOM} numbers."))); + } + let base: Vec = (0..steps as usize).map(|k| from + step * k as f64).collect(); + let one: Vec = base.iter().flat_map(|v| std::iter::repeat_n(*v, each as usize)).collect(); + Ok(one.repeat(times as usize)) +} + +/// Random Number Generation: `count` numbers from a distribution in each of `variables` +/// columns, drawn row by row. +fn random_numbers(s: &Session, p: &Json, seed: u64) -> Result { + let vars = whole(p, "variables", Some(1.0), 1.0)?; + let dist = text(p, &["distribution"]).map(norm).unwrap_or_else(|| "uniform".into()); + let cells = |rows: f64| { + if rows * vars > MAX_RANDOM as f64 { + Err(EngineError::Other(format!("Random Number Generation writes at most {MAX_RANDOM} numbers."))) + } else { + Ok(rows as usize) + } + }; + let mut g = Grid::default(); + let cols = vars as usize; + if dist == "patterned" { + let seq = pattern(p)?; + cells(seq.len() as f64)?; + for (i, x) in seq.iter().enumerate() { + for c in 0..cols { + g.num(idx(i), idx(c), *x); + } + } + return Ok(g); + } + let count = cells(whole(p, "count", None, 1.0)?)?; + let mut draw: Box f64> = match dist.as_str() { + "uniform" => { + let (a, b) = (num_or(p, "from", 0.0)?, num_or(p, "to", 1.0)?); + Box::new(move |r| a + (b - a) * r.uniform()) + } + "normal" => { + let (m, sd) = (num_or(p, "mean", 0.0)?, num_or(p, "standardDeviation", 1.0)?); + if sd < 0.0 { + return Err(EngineError::Other("The standard deviation must not be negative.".into())); + } + Box::new(move |r| m + sd * r.normal()) + } + "bernoulli" => { + let pr = probability(p)?; + Box::new(move |r| if r.uniform() < pr { 1.0 } else { 0.0 }) + } + "binomial" => { + let (pr, n) = (probability(p)?, whole(p, "trials", Some(1.0), 1.0)?); + if n > MAX_OUTCOMES { + return Err(EngineError::Other(format!("The number of trials must be at most {MAX_OUTCOMES}."))); + } + // p = 0 and p = 1 are exact: every draw is 0 or n. Otherwise one BINOM.DIST value at + // the mode ⌊(n + 1)p⌋ anchors pmf(k + 1) = pmf(k) · (n − k)/(k + 1) · p/(1 − p). + let table = if pr == 0.0 { + Table::point(0.0) + } else if pr == 1.0 { + Table::point(n) + } else { + let mode = ((n + 1.0) * pr).floor().min(n); + let at_mode = f64_of(&call("BINOM.DIST", &[Value::Number(mode), Value::Number(n), Value::Number(pr), Value::Bool(false)])); + let odds = pr / (1.0 - pr); + Table::from_mode(n, mode, at_mode, |k| (n - k) / (k + 1.0) * odds)? + }; + Box::new(move |r| table.draw(r)) + } + "poisson" => { + let lambda = num_or(p, "lambda", 1.0)?; + if lambda <= 0.0 || lambda > 100_000.0 { + return Err(EngineError::Other("Lambda must be greater than 0 and at most 100000.".into())); + } + // One POISSON.DIST value at the mode ⌊λ⌋ anchors pmf(k + 1) = pmf(k) · λ/(k + 1). + let last = (lambda + 40.0 * lambda.sqrt() + 100.0).min(MAX_OUTCOMES); + let mode = lambda.floor(); + let at_mode = f64_of(&call("POISSON.DIST", &[Value::Number(mode), Value::Number(lambda), Value::Bool(false)])); + let table = Table::from_mode(last, mode, at_mode, |k| lambda / (k + 1.0))?; + Box::new(move |r| table.draw(r)) + } + "discrete" => { + let (si, r) = input_range(s, p, &["valueRange", "values"])?; + if r.width() != 2 { + return Err(EngineError::Other("The value and probability input range must have two columns.".into())); + } + let v = variables(s, si, r, false, false, &|_| String::new())?; + let (values, probs) = match v.as_slice() { + [a, b] => (numbers(&a.vals, false)?, numbers(&b.vals, false)?), + _ => return Err(non_numeric()), + }; + if probs.iter().any(|x| !(0.0..=1.0).contains(x)) || (probs.iter().sum::() - 1.0).abs() > 1e-9 { + return Err(EngineError::Other("The probabilities must be between 0 and 1 and add up to 1.".into())); + } + let cdf: Vec = probs + .iter() + .scan(0.0, |acc, x| { + *acc += x; + Some(*acc) + }) + .collect(); + let table = Table { first: 0.0, cdf }; + Box::new(move |r| values.get(table.draw(r) as usize).copied().unwrap_or(f64::NAN)) + } + other => return Err(bad(CMD, format!("unknown distribution `{other}`"))), + }; + let mut rng = Rng::new(seed); + for i in 0..count { + for c in 0..cols { + g.num(idx(i), idx(c), draw(&mut rng)); + } + } + Ok(g) +} + +/// Whether Random Number Generation writes a patterned sequence (which uses no seed). +fn patterned(p: &Json) -> bool { + text(p, &["distribution"]).map(norm).is_some_and(|d| d == "patterned") +} + +fn random_sampling(p: &Json) -> bool { + text(p, &["method"]).is_some_and(|m| m.eq_ignore_ascii_case("random")) +} + +/// Sampling: every `period`-th value of the input, or `samples` values drawn at random with +/// replacement, down one column. The input is read column by column (row by row for a single +/// row); blanks are skipped. +fn sampling(s: &Session, p: &Json, seed: Option) -> Result { + let (si, r) = input_range(s, p, &["range", "input"])?; + let by_rows = r.height() == 1 && r.width() > 1; + let vars = variables(s, si, r, by_rows, flag(p, "labels"), &|_| String::new())?; + let mut x = Vec::new(); + for v in &vars { + x.extend(numbers(&v.vals, true)?); + } + if x.is_empty() { + return Err(EngineError::Other("The input range holds no numbers.".into())); + } + let picked: Vec = match seed { + Some(seed) => { + let k = whole(p, "samples", None, 1.0)?; + if k > MAX_RANDOM as f64 { + return Err(EngineError::Other(format!("Sampling takes at most {MAX_RANDOM} samples."))); + } + let mut rng = Rng::new(seed); + (0..k as usize).filter_map(|_| x.get(rng.below(x.len() as u64) as usize).copied()).collect() + } + None => { + let period = whole(p, "period", None, 1.0)?; + if period > x.len() as f64 { + return Err(EngineError::Other("The period must be at most the number of input values.".into())); + } + x.iter().skip(period as usize - 1).step_by(period as usize).copied().collect() + } + }; + let mut g = Grid::default(); + for (i, v) in picked.iter().enumerate() { + g.num(idx(i), 0, *v); + } + Ok(g) +} + // ---------------------------------------------------------------- output /// Writes the report at `output` (refusing to overwrite data unless `overwrite`) or on a new @@ -1150,6 +1842,7 @@ fn run(s: &mut Session, p: &Json) -> Result { return Ok(json!({"tools": TOOLS.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>()})); }; let tool = tool_id(t).ok_or_else(|| bad(CMD, format!("unknown tool `{t}`")))?; + let mut seed = None; let g = match tool { "descriptive" => descriptive(s, p)?, "histogram" => histogram(s, p)?, @@ -1162,16 +1855,38 @@ fn run(s: &mut Session, p: &Json) -> Result { "tTestPaired" | "tTestEqual" | "tTestUnequal" => t_test(s, p, tool)?, "fTest" => f_test(s, p)?, "anovaSingle" => anova_single(s, p)?, + "anovaTwoFactorWithout" => anova_two_factor_without(s, p)?, + "anovaTwoFactorWith" => anova_two_factor_with(s, p)?, + "zTest" => z_test(s, p)?, + "fourierAnalysis" => fourier_analysis(s, p)?, + "randomNumberGeneration" => { + // A patterned sequence draws nothing: its seed is neither read nor reported. + if !patterned(p) { + seed = Some(seed_of(p)?); + } + random_numbers(s, p, seed.unwrap_or(0))? + } + "sampling" => { + if random_sampling(p) { + seed = Some(seed_of(p)?); + } + sampling(s, p, seed)? + } _ => return Err(bad(CMD, format!("unknown tool `{t}`"))), }; - write_report(s, p, tool, g) + let mut out = write_report(s, p, tool, g)?; + if let Some(k) = seed { + out["seed"] = json!(k); + } + Ok(out) } #[cfg(test)] mod tests { use gridcraft_core::{CellRef, Value}; - use serde_json::json; + use serde_json::{Value as Json, json}; + use super::{MAX_OUTCOMES, call, f64_of, pmf_table}; use crate::Session; fn s() -> Session { @@ -1375,6 +2090,462 @@ mod tests { assert_eq!(n(&s, "X4"), 0.25, "4 has one value below it among five"); } + #[test] + fn two_factor_anova_without_replication_matches_a_textbook_example() { + // Hardness testing, a randomized complete block design (Montgomery, Design and Analysis + // of Experiments, Example 4.1): four tips (rows) by four coupons (columns). Published: + // SS tips 0.385, SS coupons 0.825, SS error 0.080, SS total 1.290, F tips 14.44. + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [ + ["", "Coupon 1", "Coupon 2", "Coupon 3", "Coupon 4"], + ["Tip 1", 9.3, 9.4, 9.6, 10.0], + ["Tip 2", 9.4, 9.3, 9.8, 9.9], + ["Tip 3", 9.2, 9.4, 9.5, 9.7], + ["Tip 4", 9.7, 9.6, 10.0, 10.2], + ]}), + ) + .unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "Anova: Two-Factor Without Replication", "range": "A1:E5", "labels": true, "output": "H1"})) + .unwrap(); + assert_eq!(v(&s, "H1"), Value::text("Anova: Two-Factor Without Replication")); + assert_eq!((v(&s, "H4"), n(&s, "I4")), (Value::text("Tip 1"), 4.0)); + assert!(close(n(&s, "J4"), 38.3, 1e-12), "sum of tip 1"); + assert_eq!(v(&s, "H9"), Value::text("Coupon 1")); + assert!(close(n(&s, "K12"), 9.95, 1e-12), "average of coupon 4"); + assert_eq!(v(&s, "H17"), Value::text("Rows")); + assert!(close(n(&s, "I17"), 0.385, 1e-12)); + assert!(close(n(&s, "I18"), 0.825, 1e-12)); + assert!(close(n(&s, "I19"), 0.080, 1e-12)); + assert!(close(n(&s, "I21"), 1.290, 1e-12)); + assert_eq!((n(&s, "J17"), n(&s, "J19"), n(&s, "J21")), (3.0, 9.0, 15.0)); + assert!(close(n(&s, "L17"), 14.4375, 1e-9), "F for tips"); + assert!(close(n(&s, "M17"), eval(&mut s, "=F.DIST.RT(14.4375,3,9)"), 1e-9)); + assert!(close(n(&s, "N17"), eval(&mut s, "=F.INV.RT(0.05,3,9)"), 1e-9)); + assert!(v(&s, "L19").is_empty(), "the error line has no F"); + } + + #[test] + fn two_factor_anova_with_replication_matches_a_textbook_example() { + let mut s = s(); + // Battery life (Montgomery, Example 5.1): three materials (samples of four rows) by three + // temperatures (columns). Published: SS material 10683.72, SS temperature 39118.72, + // SS interaction 9613.78, SS error 18230.75 on 27 df, SS total 77646.97. + s.execute( + "range.setValues", + json!({"range": "A20", "values": [ + ["Material", "15 F", "70 F", "125 F"], + ["Type 1", 130, 34, 20], ["", 155, 40, 70], ["", 74, 80, 82], ["", 180, 75, 58], + ["Type 2", 150, 136, 25], ["", 188, 122, 70], ["", 159, 106, 58], ["", 126, 115, 45], + ["Type 3", 138, 174, 96], ["", 110, 120, 104], ["", 168, 150, 82], ["", 160, 139, 60], + ]}), + ) + .unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "anovaTwoFactorWith", "range": "A20:D32", "rowsPerSample": 4, "newSheet": "Battery"})) + .unwrap(); + assert_eq!(v(&s, "Battery!A3"), Value::text("SUMMARY")); + assert_eq!((v(&s, "Battery!B3"), v(&s, "Battery!E3")), (Value::text("15 F"), Value::text("Total"))); + assert_eq!(v(&s, "Battery!A4"), Value::text("Type 1")); + assert_eq!((n(&s, "Battery!B5"), n(&s, "Battery!B6"), n(&s, "Battery!E5")), (4.0, 539.0, 12.0)); + assert_eq!(v(&s, "Battery!A22"), Value::text("Total")); + assert_eq!(n(&s, "Battery!B24"), 1738.0, "sum of the 15 F column"); + assert_eq!(v(&s, "Battery!A29"), Value::text("ANOVA")); + assert_eq!( + (v(&s, "Battery!A31"), v(&s, "Battery!A34"), v(&s, "Battery!A36")), + (Value::text("Sample"), Value::text("Within"), Value::text("Total")) + ); + assert!(close(n(&s, "Battery!B31"), 10683.72, 0.01)); + assert!(close(n(&s, "Battery!B32"), 39118.72, 0.01)); + assert!(close(n(&s, "Battery!B33"), 9613.78, 0.01)); + assert!(close(n(&s, "Battery!B34"), 18230.75, 0.01)); + assert!(close(n(&s, "Battery!B36"), 77646.97, 0.01)); + assert_eq!((n(&s, "Battery!C33"), n(&s, "Battery!C34"), n(&s, "Battery!C36")), (4.0, 27.0, 35.0)); + assert!(close(n(&s, "Battery!E31"), 7.91, 0.005) && close(n(&s, "Battery!E32"), 28.97, 0.005) && close(n(&s, "Battery!E33"), 3.56, 0.005)); + assert!(close(n(&s, "Battery!F33"), 0.0186, 0.00005), "interaction P-value"); + // Rows per sample must divide the data rows. + let bad = json!({"tool": "anovaTwoFactorWith", "range": "Sheet1!A20:D32", "rowsPerSample": 5, "output": "F60"}); + assert!(s.execute("data.analysisToolPak", bad).is_err()); + } + + #[test] + fn z_test_with_known_variances() { + // x̄ = 3 (n = 5, σ² = 2), ȳ = 4 (n = 3, σ² = 6): z = −1/√2.4. Critical values from the + // standard normal table: 1.644854 (one tail) and 1.959964 (two tails) at α = 0.05. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["x", "y"], [1, 2], [2, 4], [3, 6], [4, null], [5, null]]})).unwrap(); + s.execute( + "data.analysisToolPak", + json!({"tool": "zTest", "range1": "A1:A6", "range2": "B1:B6", "labels": true, "variance1": 2, "variance2": 6, "output": "D1"}), + ) + .unwrap(); + assert_eq!(v(&s, "D1"), Value::text("z-Test: Two Sample for Means")); + assert_eq!((v(&s, "E3"), v(&s, "F3")), (Value::text("x"), Value::text("y"))); + assert_eq!((n(&s, "E4"), n(&s, "F4"), n(&s, "E5"), n(&s, "F6")), (3.0, 4.0, 2.0, 3.0)); + let z = -1.0 / 2.4f64.sqrt(); + assert!(close(n(&s, "E8"), z, 1e-12)); + let tail = 1.0 - eval(&mut s, &format!("=NORM.S.DIST({},TRUE)", -z)); + assert!(close(n(&s, "E9"), tail, 1e-12) && close(n(&s, "E9"), 0.2593, 1e-4)); + assert!(close(n(&s, "E10"), 1.644_853_6, 1e-6)); + assert!(close(n(&s, "E11"), 2.0 * tail, 1e-12)); + assert!(close(n(&s, "E12"), 1.959_964_0, 1e-6)); + // Unknown or non-positive variances are refused. + assert!(s.execute("data.analysisToolPak", json!({"tool": "zTest", "range1": "A2:A6", "range2": "B2:B4", "output": "H1"})).is_err()); + let zero = json!({"tool": "zTest", "range1": "A2:A6", "range2": "B2:B4", "variance1": 0, "variance2": 1, "output": "H1"}); + assert!(s.execute("data.analysisToolPak", zero).is_err()); + } + + #[test] + fn fourier_analysis_matches_known_transforms() { + let mut s = s(); + // DFT of 1, 2, 3, 4: 10, −2+2i, −2, −2−2i. + s.execute("range.setValues", json!({"range": "A1", "values": [["Signal"], [1], [2], [3], [4]]})).unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "Fourier Analysis", "range": "A1:A5", "labels": true, "output": "C1"})).unwrap(); + let col = |s: &Session, c: &str, rows: std::ops::RangeInclusive| rows.map(|r| v(s, &format!("{c}{r}"))).collect::>(); + assert_eq!(col(&s, "C", 1..=5), ["Signal", "10", "-2+2i", "-2", "-2-2i"].map(Value::text).to_vec()); + // The inverse transform of that output returns the signal. + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "C2:C5", "inverse": true, "output": "E1"})).unwrap(); + assert_eq!(col(&s, "E", 1..=4), ["1", "2", "3", "4"].map(Value::text).to_vec()); + // cos(2πn/8) over eight points: N/2 = 4 at the first and last frequencies, 0 elsewhere. + let wave: Vec = (0..8).map(|k| json!([(std::f64::consts::PI * k as f64 / 4.0).cos()])).collect(); + s.execute("range.setValues", json!({"range": "G1", "values": wave})).unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "G1:G8", "output": "H1"})).unwrap(); + assert_eq!(col(&s, "H", 1..=8), ["0", "4", "0", "0", "0", "0", "0", "4"].map(Value::text).to_vec()); + // Complex text input; a single value is its own transform. + s.execute("range.setValues", json!({"range": "J1", "values": [["3+4i"], ["1-i"]]})).unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "J1:J2", "output": "K1"})).unwrap(); + assert_eq!(col(&s, "K", 1..=2), ["4+3i", "2+5i"].map(Value::text).to_vec()); + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "J1", "output": "L1"})).unwrap(); + assert_eq!(v(&s, "L1"), Value::text("3+4i")); + // Three values are not a power of two; a word is not a number. + assert!(s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "A2:A4", "output": "N1"})).is_err()); + assert!(s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "A1:A4", "output": "N1"})).is_err()); + assert!(s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "A1:A8192", "output": "N1"})).is_err()); + } + + #[test] + fn fourier_analysis_keeps_a_small_imaginary_part_beside_a_large_real_one() { + let mut s = s(); + // With one value the transform is the identity and performs no arithmetic, so no part of + // the output is rounding noise: 1e14 + 1i comes back as 1e14 + 1i, forward and inverse. + s.execute("range.setValues", json!({"range": "A1", "values": [["100000000000000+1i"]]})).unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "A1", "output": "C1"})).unwrap(); + assert_eq!(eval(&mut s, "=IMREAL(C1)"), 1e14); + assert_eq!(eval(&mut s, "=IMAGINARY(C1)"), 1.0); + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "C1", "inverse": true, "output": "E1"})).unwrap(); + assert_eq!(eval(&mut s, "=IMREAL(E1)"), 1e14); + assert_eq!(eval(&mut s, "=IMAGINARY(E1)"), 1.0); + } + + #[test] + fn fourier_analysis_round_trip_recovers_small_components_beside_a_large_dc() { + let mut s = s(); + // x_n = 1e6 + 1e-7·cos(2πn/8) for n = 0 … 7. The transform of a constant c over N points is + // N·c at k = 0 and 0 elsewhere; the transform of a·cos(2πn/N) is a·N/2 at k = 1 and + // k = N − 1. So X_0 = 8·1e6 = 8e6, X_1 = X_7 = 1e-7·8/2 = 4e-7 and X_2 … X_6 = 0. The inputs + // are stored to within ulp(1e6)/2 ≈ 6e-11 and the first butterfly stage subtracts nearby + // numbers exactly, so the harmonics carry at most about 5e-10 of noise. The cleanup bound + // 8·ε·log2(8)·Σ|x_n| ≈ 8 · 2.2e-16 · 3 · 8e6 ≈ 4.3e-8 zeroes noise of that size and keeps + // 4e-7; a cutoff relative to the largest output (8e6 · 1e-13 = 8e-7) would zero the + // harmonics. + let x = |k: usize| 1e6 + 1e-7 * (std::f64::consts::PI * k as f64 / 4.0).cos(); + let wave: Vec = (0..8).map(|k| json!([x(k)])).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": wave})).unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "A1:A8", "output": "C1"})).unwrap(); + assert!(close(eval(&mut s, "=IMREAL(C1)"), 8e6, 1e-6)); + assert_eq!(eval(&mut s, "=IMAGINARY(C1)"), 0.0); + for cell in ["C2", "C8"] { + assert!(close(eval(&mut s, &format!("=IMREAL({cell})")), 4e-7, 5e-9), "{cell}"); + assert_eq!(eval(&mut s, &format!("=IMAGINARY({cell})")), 0.0, "{cell}"); + } + for r in 3..=7 { + assert_eq!(v(&s, &format!("C{r}")), Value::text("0"), "C{r}"); + } + // The inverse of that output returns each x_n to within 2e-9 (2e-15 relative); without the + // harmonics every value would come back as 1e6, off by up to 1e-7. + s.execute("data.analysisToolPak", json!({"tool": "fourierAnalysis", "range": "C1:C8", "inverse": true, "output": "E1"})).unwrap(); + for k in 0..8 { + let cell = format!("E{}", k + 1); + assert!(close(eval(&mut s, &format!("=IMREAL({cell})")), x(k), 2e-9), "{cell}"); + assert_eq!(eval(&mut s, &format!("=IMAGINARY({cell})")), 0.0, "{cell}"); + } + } + + /// Column `col` of sheet `sheet`, rows 1 to `rows`, as numbers. + fn numbers_of(s: &Session, sheet: &str, col: &str, rows: u32) -> Vec { + (1..=rows).map(|r| n(s, &format!("{sheet}!{col}{r}"))).collect() + } + fn mean_var(x: &[f64]) -> (f64, f64) { + let m = x.iter().sum::() / x.len() as f64; + (m, x.iter().map(|v| (v - m).powi(2)).sum::() / (x.len() - 1) as f64) + } + + #[test] + fn random_numbers_are_seeded_and_follow_their_distributions() { + let mut s = s(); + let rng = |s: &mut Session, sheet: &str, p: Json| { + let mut p = p; + p["tool"] = json!("randomNumberGeneration"); + p["newSheet"] = json!(sheet); + s.execute("data.analysisToolPak", p).unwrap() + }; + // The same seed repeats the numbers; the result reports the seed. + let r = rng(&mut s, "U1", json!({"variables": 2, "count": 2000, "distribution": "uniform", "from": 2, "to": 4, "seed": 42})); + assert_eq!((r["seed"].as_u64(), r["range"].as_str()), (Some(42), Some("A1:B2000"))); + rng(&mut s, "U2", json!({"variables": 2, "count": 2000, "distribution": "uniform", "from": 2, "to": 4, "seed": 42})); + let (a, b) = (numbers_of(&s, "U1", "B", 2000), numbers_of(&s, "U2", "B", 2000)); + assert_eq!(a, b); + assert!(a.iter().all(|x| (2.0..4.0).contains(x))); + // Uniform on [2, 4]: mean 3, variance 1/3. + let (m, var) = mean_var(&a); + assert!((m - 3.0).abs() < 0.05 && (var - 1.0 / 3.0).abs() < 0.03, "{m} {var}"); + let unseeded = rng(&mut s, "U3", json!({"count": 1})); + assert!(unseeded["seed"].as_u64().is_some(), "a seed is chosen and reported"); + // Normal(10, 2): mean 10, variance 4. + rng(&mut s, "N", json!({"count": 20000, "distribution": "normal", "mean": 10, "standardDeviation": 2, "seed": 7})); + let (m, var) = mean_var(&numbers_of(&s, "N", "A", 20000)); + assert!((m - 10.0).abs() < 0.05 && (var - 4.0).abs() < 0.15, "{m} {var}"); + // Binomial(20, 0.3): mean np = 6, variance np(1 − p) = 4.2. + rng(&mut s, "B", json!({"count": 20000, "distribution": "binomial", "p": 0.3, "trials": 20, "seed": 8})); + let x = numbers_of(&s, "B", "A", 20000); + assert!(x.iter().all(|v| v.fract() == 0.0 && (0.0..=20.0).contains(v))); + let (m, var) = mean_var(&x); + assert!((m - 6.0).abs() < 0.08 && (var - 4.2).abs() < 0.2, "{m} {var}"); + // Poisson(3.5): mean and variance 3.5. + rng(&mut s, "P", json!({"count": 20000, "distribution": "poisson", "lambda": 3.5, "seed": 9})); + let (m, var) = mean_var(&numbers_of(&s, "P", "A", 20000)); + assert!((m - 3.5).abs() < 0.08 && (var - 3.5).abs() < 0.2, "{m} {var}"); + // Bernoulli(0.25) and a discrete distribution on {1, 2} with P(2) = 0.75. + rng(&mut s, "Ber", json!({"count": 4000, "distribution": "bernoulli", "p": 0.25, "seed": 10})); + let ones = numbers_of(&s, "Ber", "A", 4000).iter().filter(|v| **v == 1.0).count(); + assert!((900..1100).contains(&ones), "{ones}"); + s.execute("range.setValues", json!({"range": "Sheet1!A1", "values": [[1, 0.25], [2, 0.75]]})).unwrap(); + rng(&mut s, "D", json!({"count": 4000, "distribution": "discrete", "valueRange": "Sheet1!A1:B2", "seed": 11})); + let x = numbers_of(&s, "D", "A", 4000); + assert!(x.iter().all(|v| *v == 1.0 || *v == 2.0)); + let twos = x.iter().filter(|v| **v == 2.0).count(); + assert!((2900..3100).contains(&twos), "{twos}"); + // Patterned: 1 to 3, each number twice, the sequence twice. + rng(&mut s, "Pat", json!({"distribution": "patterned", "from": 1, "to": 3, "step": 1, "repeatEach": 2, "repeatSequence": 2})); + assert_eq!(numbers_of(&s, "Pat", "A", 12), [1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0]); + assert!(v(&s, "Pat!A13").is_empty()); + // Output is bounded; parameters are checked. + for p in [ + json!({"count": 2_000_000}), + json!({"count": 1000, "variables": 2000}), + json!({"count": 0}), + json!({"count": 1.5}), + json!({"distribution": "uniform"}), + json!({"count": 1, "distribution": "bernoulli", "p": 2}), + json!({"count": 1, "distribution": "binomial", "trials": 1e9}), + json!({"count": 1, "distribution": "poisson", "lambda": -1}), + json!({"count": 1, "distribution": "normal", "standardDeviation": -1}), + json!({"count": 1, "distribution": "discrete", "valueRange": "Sheet1!A1:A2"}), + json!({"count": 1, "distribution": "patterned", "from": 1, "to": 1e9, "step": 1}), + json!({"count": 1, "distribution": "patterned", "from": 3, "to": 1}), + json!({"count": 1, "distribution": "weird"}), + json!({"count": 1, "seed": -1}), + json!({"count": 1, "seed": "abc"}), + ] { + let mut q = p.clone(); + q["tool"] = json!("randomNumberGeneration"); + q["output"] = json!("Sheet1!H1"); + assert!(s.execute("data.analysisToolPak", q).is_err(), "{p}"); + } + s.execute("range.setValues", json!({"range": "Sheet1!A1", "values": [[1, 0.5], [2, 0.6]]})).unwrap(); + let p = + json!({"tool": "randomNumberGeneration", "count": 1, "distribution": "discrete", "valueRange": "Sheet1!A1:B2", "output": "Sheet1!H1"}); + assert!(s.execute("data.analysisToolPak", p).is_err(), "probabilities must add up to 1"); + } + + #[test] + fn binomial_and_poisson_tables_agree_with_the_worksheet_functions() { + // The recurrence outward from the mode reproduces BINOM.DIST and POISSON.DIST at every + // tabulated outcome, the table carries the whole distribution, and it is only as wide as + // the outcomes that matter. The worksheet functions themselves are accurate to about + // n · ε for n trials (their logarithms are of that size), hence the moderate n and λ. + let binom = |k: f64, n: f64, p: f64| f64_of(&call("BINOM.DIST", &[Value::Number(k), Value::Number(n), Value::Number(p), Value::Bool(false)])); + for (n, p) in [(20.0f64, 0.3f64), (100.0, 0.5), (50.0, 0.9), (30.0, 0.01), (7.0, 0.999), (1.0, 0.5)] { + let mode = ((n + 1.0) * p).floor().min(n); + let odds = p / (1.0 - p); + let (first, pmf) = pmf_table(n, mode, binom(mode, n, p), |k| (n - k) / (k + 1.0) * odds).unwrap(); + assert!(first >= 0.0 && first + pmf.len() as f64 - 1.0 <= n, "n={n} p={p}: {first} + {}", pmf.len()); + for (i, got) in pmf.iter().enumerate() { + let k = first + i as f64; + let want = binom(k, n, p); + assert!(((got - want) / want).abs() < 1e-12, "n={n} p={p} k={k}: {got} vs {want}"); + } + assert!((pmf.iter().sum::() - 1.0).abs() < 1e-12, "n={n} p={p}"); + } + let poisson = |k: f64, lambda: f64| f64_of(&call("POISSON.DIST", &[Value::Number(k), Value::Number(lambda), Value::Bool(false)])); + for lambda in [0.2f64, 3.5, 50.0] { + let mode = lambda.floor(); + let (first, pmf) = pmf_table(MAX_OUTCOMES, mode, poisson(mode, lambda), |k| lambda / (k + 1.0)).unwrap(); + assert_eq!(first, 0.0); + for (i, got) in pmf.iter().enumerate() { + let want = poisson(i as f64, lambda); + assert!(((got - want) / want).abs() < 1e-12, "lambda={lambda} k={i}: {got} vs {want}"); + } + assert!((pmf.iter().sum::() - 1.0).abs() < 1e-12, "lambda={lambda}"); + } + // Binomial(1e6, 0.5): standard deviation 500, so the terms vanish within about 38 + // standard deviations below the mode and the sum reaches 1 within about 8 above it: + // roughly 23,000 entries starting near 481,000, not 1,000,001 starting at 0. + let (n, p) = (1_000_000.0, 0.5); + let (first, pmf) = pmf_table(n, 500_000.0, binom(500_000.0, n, p), |k| (n - k) / (k + 1.0)).unwrap(); + assert!((470_000.0..=490_000.0).contains(&first) && pmf.len() < 40_000, "{first} + {}", pmf.len()); + // The anchor BINOM.DIST(500000, 1e6, 0.5) carries that function's error of about n · ε + // (its logarithms are near 7e5), which scales the whole table; draws divide by the total. + assert!((pmf.iter().sum::() - 1.0).abs() < 1e-8); + // A bad anchor or mode is refused rather than tabulated. + assert!(pmf_table(10.0, 5.0, f64::NAN, |_| 1.0).is_err()); + assert!(pmf_table(10.0, 5.0, 0.0, |_| 1.0).is_err()); + assert!(pmf_table(10.0, 11.0, 0.5, |_| 1.0).is_err()); + } + + #[test] + #[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm + fn binomial_and_poisson_draws_at_the_limits_are_tabulated_quickly() { + let mut s = s(); + // The tables cover the outcomes around the mode, not every one of the 1,000,001 for a + // million trials, so the largest inputs cost milliseconds. Binomial(1e6, 0.5) has mean + // 500000 and standard deviation 500: 1000 draws put the sample mean within 500/√1000 ≈ 16 + // of it, so 100 is six standard errors. Poisson(1e5) has mean 1e5 and standard deviation + // 316: 1000 draws put the mean within 10, so 60 is six standard errors. With p = 1 every + // draw is exactly 1e6, with p = 0 exactly 0. + let mut draw = |sheet: &str, dist: Json| { + let mut dist = dist; + dist["tool"] = json!("randomNumberGeneration"); + dist["count"] = json!(1000); + dist["seed"] = json!(3); + dist["newSheet"] = json!(sheet); + s.execute("data.analysisToolPak", dist).unwrap(); + }; + let start = std::time::Instant::now(); + draw("Half", json!({"distribution": "binomial", "p": 0.5, "trials": 1_000_000})); + draw("Sure", json!({"distribution": "binomial", "p": 1, "trials": 1_000_000})); + draw("Never", json!({"distribution": "binomial", "p": 0, "trials": 1_000_000})); + draw("Pois", json!({"distribution": "poisson", "lambda": 100_000})); + let elapsed = start.elapsed(); + let (m, _) = mean_var(&numbers_of(&s, "Half", "A", 1000)); + assert!((m - 500_000.0).abs() < 100.0, "{m}"); + assert!(numbers_of(&s, "Sure", "A", 1000).iter().all(|v| *v == 1_000_000.0)); + assert!(numbers_of(&s, "Never", "A", 1000).iter().all(|v| *v == 0.0)); + let (m, _) = mean_var(&numbers_of(&s, "Pois", "A", 1000)); + assert!((m - 100_000.0).abs() < 60.0, "{m}"); + assert!(elapsed < std::time::Duration::from_secs(2), "{elapsed:?}"); + } + + #[test] + fn sampling_periodic_and_random() { + let mut s = s(); + let values: Vec = (1..=10).map(|k| json!([k])).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap(); + s.execute("data.analysisToolPak", json!({"tool": "Sampling", "range": "A1:A10", "period": 3, "output": "C1"})).unwrap(); + assert_eq!(numbers_of(&s, "Sheet1", "C", 3), [3.0, 6.0, 9.0]); + assert!(v(&s, "C4").is_empty()); + let r = s + .execute( + "data.analysisToolPak", + json!({"tool": "sampling", "range": "A1:A10", "method": "random", "samples": 20, "seed": 5, "output": "D1"}), + ) + .unwrap(); + assert_eq!(r["seed"], 5); + s.execute( + "data.analysisToolPak", + json!({"tool": "sampling", "range": "A1:A10", "method": "random", "samples": 20, "seed": 5, "output": "E1"}), + ) + .unwrap(); + let (a, b) = (numbers_of(&s, "Sheet1", "D", 20), numbers_of(&s, "Sheet1", "E", 20)); + assert_eq!(a, b, "the same seed repeats the sample"); + assert!(a.iter().all(|x| x.fract() == 0.0 && (1.0..=10.0).contains(x))); + for p in [ + json!({"range": "A1:A10", "period": 11}), + json!({"range": "A1:A10", "period": 0}), + json!({"range": "A1:A10"}), + json!({"range": "A1:A10", "method": "random", "samples": 2_000_000}), + json!({"range": "B1:B10", "period": 1}), + ] { + let mut q = p.clone(); + q["tool"] = json!("sampling"); + q["output"] = json!("G1"); + assert!(s.execute("data.analysisToolPak", q).is_err(), "{p}"); + } + } + + #[test] + fn options_typed_as_text_that_is_not_a_number_are_refused() { + // A form sends its options as text; one that doesn't read as a number (a decimal comma, + // a typo) is an error, not the default, while a blank option takes the default. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [2, 4], [3, 6]]})).unwrap(); + let rng = |dist: &str| json!({"tool": "randomNumberGeneration", "count": 3, "distribution": dist, "seed": 1, "outputTo": "newSheet"}); + let z = json!({"tool": "zTest", "range1": "Sheet1!A1:A3", "range2": "Sheet1!B1:B3", "variance1": 1, "variance2": 4, "outputTo": "newSheet"}); + let with = |base: &Json, key: &str, val: &str| { + let mut p = base.clone(); + p[key] = json!(val); + p + }; + let cases = [ + (rng("uniform"), "from"), + (rng("uniform"), "to"), + (rng("normal"), "mean"), + (rng("normal"), "standardDeviation"), + (rng("bernoulli"), "p"), + (rng("binomial"), "p"), + (rng("poisson"), "lambda"), + (rng("patterned"), "step"), + (rng("uniform"), "variables"), + (z.clone(), "hypothesizedDifference"), + ]; + for (base, key) in cases { + let e = s.execute("data.analysisToolPak", with(&base, key, "0,3")).unwrap_err().to_string(); + assert!(e.contains(key), "{key}: {e}"); + s.execute("data.analysisToolPak", with(&base, key, " ")).unwrap_or_else(|e| panic!("{key} blank: {e}")); + } + } + + #[test] + fn an_alpha_that_is_not_a_number_is_refused() { + // A decimal comma or a typo in Alpha must not run the test at 0.05 without a word. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["", "c1", "c2"], ["r1", 1, 2], ["r1", 2, 4], ["r2", 3, 6], ["r2", 5, 9]]})) + .unwrap(); + for base in [ + json!({"tool": "zTest", "range1": "Sheet1!B2:B5", "range2": "Sheet1!C2:C5", "variance1": 1, "variance2": 4}), + json!({"tool": "anovaTwoFactorWithout", "range": "Sheet1!B2:C5"}), + json!({"tool": "anovaTwoFactorWith", "range": "Sheet1!A1:C5", "rowsPerSample": 2}), + json!({"tool": "tTestEqual", "range1": "Sheet1!B2:B5", "range2": "Sheet1!C2:C5"}), + ] { + let mut p = base.clone(); + p["alpha"] = json!("0,01"); + let e = s.execute("data.analysisToolPak", p).unwrap_err().to_string(); + assert!(e.contains("alpha"), "{base}: {e}"); + let mut p = base.clone(); + p["alpha"] = json!(""); + s.execute("data.analysisToolPak", p).unwrap_or_else(|e| panic!("{base} blank alpha: {e}")); + } + } + + #[test] + fn a_patterned_sequence_ignores_the_seed() { + // Patterned generation draws nothing, so a seed left in the form (even one that isn't a + // number) neither blocks the run nor shows up in the result. + let mut s = s(); + for seed in [json!("abc"), json!(7)] { + let r = s + .execute( + "data.analysisToolPak", + json!({"tool": "randomNumberGeneration", "distribution": "patterned", "from": 1, "to": 3, "seed": seed, "outputTo": "newSheet"}), + ) + .unwrap_or_else(|e| panic!("seed {seed}: {e}")); + assert!(r.get("seed").is_none(), "seed {seed}: {r}"); + } + // A random distribution still refuses the bad seed. + let bad = json!({"tool": "randomNumberGeneration", "distribution": "uniform", "count": 2, "seed": "abc", "outputTo": "newSheet"}); + assert!(s.execute("data.analysisToolPak", bad).is_err()); + } + #[test] fn hostile_inputs_are_refused_without_panicking() { let mut s = s(); @@ -1399,6 +2570,11 @@ mod tests { json!({"tool": "anovaSingle", "range": "A1:A2"}), json!({"tool": "correlation", "range": "A1:B2", "alpha": "x", "groupedBy": 5}), json!({"tool": "descriptive", "range": "A1:A2", "confidence": 250}), + json!({"tool": "anovaTwoFactorWithout", "range": "A1:B2"}), + json!({"tool": "anovaTwoFactorWithout", "range": "A1:A2"}), + json!({"tool": "anovaTwoFactorWith", "range": "A1:B2", "rowsPerSample": 2}), + json!({"tool": "anovaTwoFactorWith", "range": "A1:B2", "rowsPerSample": 1e300}), + json!({"tool": "zTest", "range1": "A1", "range2": "A2", "variance1": "x", "variance2": -1}), ] { assert!(s.execute("data.analysisToolPak", p.clone()).is_err(), "{p}"); } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index fd2a6e7a2..e03929bb3 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { "Add Chart Element", ["Chart Design", "Chart Layouts"], None, - "{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}", + "{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}", has_doc, add_element ), diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs index b6761f1a9..8d9af2064 100644 --- a/crates/engine/src/cmd/extra/chart.rs +++ b/crates/engine/src/cmd/extra/chart.rs @@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result { } } "dataLabels" => c.data_labels = on, + "dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?, "gridlines" => c.gridlines = on, "axisTitles" => { c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index ad6973b23..3c62e469f 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { series, legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 1, x_title: None, diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index f057bc430..d72f814a1 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -69,6 +69,15 @@ pub fn specs() -> Vec { ), cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents), cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), + cmd!( + query "formulas.traceError", + "Trace Error", + ["Formulas", "Formula Auditing"], + None, + "{cell?} → {sheet, cell, error, source: {sheet, cell}, arrows: [{from: {sheet, range}, to: {sheet, cell}, kind: \"error\"|\"precedent\"}], truncated}: from the cell, follows the first reference that shows the same error (in a range, its first cell showing it) back to the cell the error starts in, `source`. `error` arrows link the cells on the way; `precedent` arrows lead from the source's own references into it. A cell without an error gives `error` and `source` null and no arrows. A chain longer than 10,000 links stops there with `truncated` true and `source` null.", + has_doc, + trace_error + ), cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents), cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents), cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()), @@ -653,6 +662,79 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { Ok(Json::Array(v)) } +/// Trace Error goes back at most this many links; a longer chain stops there. +const TRACE_ERROR_LINKS: usize = 10_000; + +/// Trace Error: from the cell, the first reference in its formula that shows the same error +/// (in a range, the range's first cell showing it, row by row), then that cell's, and so on to the +/// cell the error starts in: one none of whose references shows it, or one without references. +/// Arrows link the cells on the way, and lead from the source's own references into it. +fn trace_error(s: &mut Session, p: &Json) -> Result { + use std::collections::HashSet; + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let sheet = d.wb.active_sheet(); + let name = |si: usize| d.wb.sheet(si).map(|sh| sh.name.clone()); + let cell = |(si, c): (usize, CellRef)| json!({"sheet": name(si), "cell": c.a1()}); + let Some(error) = d.wb.sheet(sheet).and_then(|sh| sh.value(at).as_error()) else { + return Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": null, "source": null, "arrows": [], "truncated": false})); + }; + // A value spilled by a dynamic array comes from its anchor's formula. + let precedents = |(si, c): (usize, CellRef)| { + let anchor = d.wb.sheet(si).filter(|sh| sh.cell(c).is_none_or(|x| x.formula.is_none())).and_then(|sh| sh.spill_anchor(c)); + d.calc.graph.precedents_of((si, anchor.unwrap_or(c))) + }; + // The walk keeps to cells it hasn't been to, so a circular reference ends it. + let mut chain = vec![(sheet, at)]; + let mut seen: HashSet<(usize, CellRef)> = HashSet::from([(sheet, at)]); + let mut truncated = false; + let mut last = (sheet, at); + while let Some(next) = precedents(last).iter().find_map(|a| first_showing(&d.wb, a, error)) { + if !seen.insert(next) { + break; + } + if chain.len() > TRACE_ERROR_LINKS { + truncated = true; + break; + } + chain.push(next); + last = next; + } + let mut arrows = Vec::new(); + // A chain cut short hasn't reached its source. + if !truncated { + let mut drawn = HashSet::new(); + for a in precedents(last) { + if drawn.insert(a) { + arrows.push(json!({"from": {"sheet": name(a.sheet), "range": a.range.a1()}, "to": cell(last), "kind": "precedent"})); + } + } + } + // From the source outwards, as the error travels. + for pair in chain.windows(2).rev() { + if let [to, from] = pair { + arrows.push(json!({"from": {"sheet": name(from.0), "range": from.1.a1()}, "to": cell(*to), "kind": "error"})); + } + } + let source = if truncated { Json::Null } else { cell(last) }; + Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": error.as_str(), "source": source, "arrows": arrows, "truncated": truncated})) +} + +/// The first cell of an area, row by row, that shows `error`: a stored cell or a value spilled +/// into the area by a dynamic array. +fn first_showing(wb: &Workbook, a: &gridcraft_calc::Area, error: CellError) -> Option<(usize, CellRef)> { + let sh = wb.sheet(a.sheet)?; + let holds = |c: &Cell| !c.value.is_empty() || c.formula.is_some(); + let stored = sh.cells.iter_range(a.range).find(|(_, c)| holds(c) && c.value.as_error() == Some(error)).map(|(at, _)| at); + let spilled = sh + .spill + .range(a.range.start..) + .take_while(|(at, _)| **at <= a.range.end) + .find(|(at, v)| a.range.contains(**at) && v.as_error() == Some(error) && !sh.cell(**at).is_some_and(holds)) + .map(|(at, _)| *at); + stored.into_iter().chain(spilled).min().map(|c| (a.sheet, c)) +} + /// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on /// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]). fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 6e2a3895b..04dc1844e 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1,7 +1,7 @@ //! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes, //! conditional formatting (Home › Styles) and symbols. -use gridcraft_core::{CellRef, MAX_ROWS, RangeRef}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; use gridcraft_formula::adjust::{Axis, Edit}; use gridcraft_model::{ Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill, @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Chart Properties", ["Chart Design"], None, - "{chart?: id, type?, subtype?, 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?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", has_doc, chart_set ), @@ -234,14 +234,30 @@ fn block(s: &Session, p: &Json) -> Result { } fn chart_areas(s: &Session, p: &Json) -> Result> { - if p.get("range").is_some() { - return target_ranges(s, p); + let mut areas = if p.get("range").is_some() { + target_ranges(s, p)? + } else if s.doc()?.selection.ranges.len() > 1 { + s.doc()?.selection.ranges.clone() + } else { + vec![block(s, p)?] + }; + // Whole columns/rows span a million cells: clip their far edges to the cells in use, so only + // a block that really is too large gets refused. Bounded areas keep their extent as given. + let sheet = target_sheet(s, p)?; + if let Some(used) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) { + // Areas that start outside the used range keep their extent as given. + for r in &mut areas { + let whole_cols = r.start.row == 0 && r.end.row >= MAX_ROWS - 1; + let whole_rows = r.start.col == 0 && r.end.col >= MAX_COLS - 1; + if whole_cols && r.start.row <= used.end.row { + r.end.row = r.end.row.min(used.end.row); + } + if whole_rows && r.start.col <= used.end.col { + r.end.col = r.end.col.min(used.end.col); + } + } } - let d = s.doc()?; - if d.selection.ranges.len() > 1 { - return Ok(d.selection.ranges.clone()); - } - Ok(vec![block(s, p)?]) + Ok(areas) } // ---------------------------------------------------------------- tables @@ -823,6 +839,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { series, legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, data_labels: false, + data_table: None, gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), style: 1, x_title: None, @@ -870,7 +887,11 @@ fn chart_set(s: &mut Session, p: &Json) -> Result { let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) }; if let Some(t) = str_param(p, "type") { c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or("")); + if !c.supports_data_table() { + c.data_table = None; + } } + update_data_table(c, p, bool_param(p, "dataTable"))?; if let Some(method) = quartiles { for series in &mut c.series { series.quartiles = method; @@ -922,6 +943,32 @@ fn chart_set(s: &mut Session, p: &Json) -> Result { }) } +/// Shared by Chart Properties and Add Chart Element; no separate UI state. +pub(super) fn update_data_table(c: &mut Chart, p: &Json, on: Option) -> Result<()> { + if p.get("dataTable").is_some_and(|v| !v.is_boolean()) { + return Err(bad("chart", "`dataTable` must be a boolean")); + } + if on == Some(false) { + c.data_table = None; + return Ok(()); + } + let fields = ["dataTableKeys", "dataTableHorizontalBorder", "dataTableVerticalBorder", "dataTableOutline"]; + if on == Some(true) || fields.iter().any(|k| p.get(k).is_some()) { + if !c.supports_data_table() { + return Err(bad("chart", "data tables need a column, bar, line, area or compatible combo chart")); + } + let table = c.data_table.get_or_insert_with(Default::default); + for (key, value) in + fields.into_iter().zip([&mut table.show_keys, &mut table.horizontal_border, &mut table.vertical_border, &mut table.outline]) + { + if let Some(v) = p.get(key) { + *value = v.as_bool().ok_or_else(|| bad("chart", format!("`{key}` must be a boolean")))?; + } + } + } + Ok(()) +} + fn chart_switch(s: &mut Session, p: &Json) -> Result { let (si, ci) = find_chart(s, p)?; edit(s, |cx| { @@ -1751,6 +1798,86 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { mod tests { use super::*; + #[test] + fn chart_from_whole_columns_uses_the_rows_in_use() { + // #3114: selecting two whole columns of data refused the chart as "range too large". + let mut s = Session::new(); + s.new_workbook(); + let values: Vec> = (1..=5).map(|i| vec![i.to_string(), (i * 10).to_string()]).collect(); + s.execute("range.setValues", json!({"range": "A1:B5", "values": values})).unwrap(); + s.execute("selection.set", json!({"range": "A:B"})).unwrap(); + s.execute("insert.chart", json!({"type": "line"})).unwrap(); + let d = s.doc().unwrap(); + let chart = d.wb.active().unwrap().charts.last().unwrap(); + let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect(); + assert_eq!(values, ["Sheet1!$A$1:$A$5", "Sheet1!$B$1:$B$5"]); + // A block that really is too large still is. + s.execute("cell.set", json!({"cell": "B600000", "input": "1"})).unwrap(); + assert!(s.execute("insert.chart", json!({"range": "A:B", "type": "line"})).is_err()); + // A bounded block keeps its extent, even where it runs past the data. + s.execute("insert.chart", json!({"range": "A1:A20", "type": "line"})).unwrap(); + let d = s.doc().unwrap(); + let chart = d.wb.active().unwrap().charts.last().unwrap(); + let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect(); + assert_eq!(values, ["Sheet1!$A$1:$A$20"]); + // A bounded block entirely below the data keeps its extent. + s.execute("insert.chart", json!({"range": "C700000:C700010", "type": "line"})).unwrap(); + let d = s.doc().unwrap(); + let chart = d.wb.active().unwrap().charts.last().unwrap(); + let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect(); + assert_eq!(values, ["Sheet1!$C$700000:$C$700010"]); + } + + #[test] + fn chart_data_table_commands_undo_and_follow_live_values() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap(); + s.execute("chart.addElement", json!({"element": "dataTable", "dataTableKeys": false})).unwrap(); + let get = |s: &Session| s.active().unwrap().wb.active().unwrap().charts[0].clone(); + let table = get(&s).data_table.unwrap(); + assert!(!table.show_keys && table.horizontal_border && table.vertical_border && table.outline); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(get(&s).data_table, None); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(get(&s).data_table, Some(table)); + s.execute("chart.set", json!({"dataTableKeys": true, "dataTableHorizontalBorder": false})).unwrap(); + assert!(get(&s).data_table.as_ref().unwrap().show_keys); + assert!(!get(&s).data_table.as_ref().unwrap().horizontal_border); + s.execute("cell.set", json!({"cell": "B2", "input": "=12*3"})).unwrap(); + let d = s.active().unwrap(); + assert_eq!(gridcraft_chart::resolve(&d.wb, 0, &get(&s)).series[0].values[0], Some(36.0)); + s.execute("chart.addElement", json!({"element": "dataTable", "on": false})).unwrap(); + assert_eq!(get(&s).data_table, None); + // Old JSON documents have no table field. + let mut json = serde_json::to_value(get(&s)).unwrap(); + json.as_object_mut().unwrap().remove("data_table"); + assert_eq!(serde_json::from_value::(json).unwrap().data_table, None); + } + + #[test] + fn chart_data_table_refuses_unsupported_types_without_changing_history() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Q", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap(); + let before = s.active().unwrap().wb.clone(); + let history = s.execute("history.list", json!({})).unwrap(); + for params in [json!({"dataTable": true}), json!({"dataTableKeys": true}), json!({"dataTable": "yes"})] { + assert!(s.execute("chart.set", params).is_err()); + assert_eq!(s.active().unwrap().wb, before); + assert_eq!(s.execute("history.list", json!({})).unwrap(), history); + } + assert!(s.execute("chart.addElement", json!({"element": "dataTable"})).is_err()); + s.execute("chart.set", json!({"type": "line", "dataTable": true})).unwrap(); + assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some()); + s.execute("chart.set", json!({"type": "scatter"})).unwrap(); + assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some()); + } + #[test] fn manage_rules_delete_then_move() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs index bdd5e77d1..ac486f837 100644 --- a/crates/engine/src/cmd/insert/chart.rs +++ b/crates/engine/src/cmd/insert/chart.rs @@ -260,6 +260,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { series, legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, data_labels: false, + data_table: None, gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), style: 1, x_title: None, diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 30c11234a..e91a269fb 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "freeze": sh.freeze, "merges": sh.merges.iter().map(|m| m.a1()).collect::>(), "tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::>()})).collect::>(), - "charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::>()})).collect::>(), + "charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::>()})).collect::>(), "images": sh.images.len(), "shapes": sh.shapes.len(), "conditionalFormats": sh.cond_formats.len(), diff --git a/crates/engine/src/cmd/pivot/charts.rs b/crates/engine/src/cmd/pivot/charts.rs index 8ae00b161..d7a3b73b1 100644 --- a/crates/engine/src/cmd/pivot/charts.rs +++ b/crates/engine/src/cmd/pivot/charts.rs @@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { series, legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, data_labels: false, + data_table: None, gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), style: 1, x_title: None, diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index cda52f31d..b3ce40d58 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1,20 +1,20 @@ //! Printing: Page Setup, pagination and PDF output (File › Print, File › Export › PDF). //! -//! Sheets are paginated like Excel's default "down, then over": the print area (or the used -//! range plus drawing objects) is split into bands of rows and columns that fit the printable -//! area at the page scale, honouring manual breaks, repeated title rows/columns, headings, Fit -//! To and centring. Each page is drawn into a PDF with `gridcraft-pdf`: fills, gridlines, -//! borders, formatted text (fonts mapped to the standard 14 PDF fonts), merged cells, wrapping, -//! overflow into empty neighbours, conditional formats, table styles, charts, images, shapes and -//! sparklines. Headers and footers support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` -//! codes. +//! Sheets are paginated like Excel: the print area (or the used range plus drawing objects) is +//! split into bands of rows and columns that fit the printable area at the page scale, honouring +//! manual breaks, repeated title rows/columns, headings, Fit To and centring, and the pages follow +//! the sheet's page order ("down, then over" by default, or "over, then down"). Each page is +//! drawn into a PDF with `gridcraft-pdf`: fills, gridlines, borders, formatted text (fonts mapped +//! to the standard 14 PDF fonts), merged cells, wrapping, overflow into empty neighbours, +//! conditional formats, table styles, charts, images, shapes and sparklines. Headers and footers +//! support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` codes. use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; #[cfg(test)] use gridcraft_model::{Alignment, Image, SheetViewMode}; use gridcraft_model::{ - Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable, - Underline, VAlign, Visibility, Workbook, + Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, + StyleTable, Underline, VAlign, Visibility, Workbook, }; use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb}; use serde_json::{Value as Json, json}; @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Print Preview", ["File"], None, - "{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, width, height}]}", + "{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, pageOrder, width, height}]} in print order", has_doc, print_preview ), @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "Page Setup…", ["Page Layout", "Page Setup"], None, - "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings", + "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"], pageOrder?: downThenOver|overThenDown} → the sheet's print settings", has_doc, page_setup ), @@ -222,6 +222,7 @@ fn print_preview(s: &mut Session, p: &Json) -> Result { "scale": (lay.scale * 1000.0).round() / 10.0, "paper": lay.settings.paper, "orientation": if lay.settings.orientation == Orientation::Landscape { "landscape" } else { "portrait" }, + "pageOrder": lay.settings.page_order.id(), "width": lay.page_w, "height": lay.page_h, })); @@ -244,6 +245,16 @@ fn page_setup(s: &mut Session, p: &Json) -> Result { Some("landscape") => Some(Orientation::Landscape), Some(o) => return Err(bad(CMD, format!("orientation must be portrait or landscape, not `{o}`"))), }; + let page_order = match p.get("pageOrder") { + None => None, + Some(v) => match v.as_str().and_then(PageOrder::parse) { + Some(o) => Some(o), + None => { + let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string); + return Err(bad(CMD, format!("pageOrder must be downThenOver or overThenDown, not `{shown}`"))); + } + }, + }; let paper = match str_param(p, "paper") { None => None, Some(name) => { @@ -385,6 +396,9 @@ fn page_setup(s: &mut Session, p: &Json) -> Result { if let Some(b) = &col_breaks { pr.col_breaks = b.clone(); } + if let Some(o) = page_order { + pr.page_order = o; + } Ok(()) })?; let pr = s.doc()?.wb.sheet(si).map(|sh| sh.print.clone()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/print/layout.rs b/crates/engine/src/cmd/print/layout.rs index 83ff45183..0f484f875 100644 --- a/crates/engine/src/cmd/print/layout.rs +++ b/crates/engine/src/cmd/print/layout.rs @@ -1,7 +1,7 @@ use super::header_footer::HF_FIXED_SIDE_MARGIN; use super::{ - Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad, - bool_param, split_sheet, str_param, + Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PageOrder, PrintSettings, RangeRef, Result, Sheet, Visibility, + Workbook, bad, bool_param, split_sheet, str_param, }; /// Cells in a page block above which only stored cells are visited (not every grid position). @@ -253,15 +253,7 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid let paginate = |lay: &mut SheetLayout, manual: bool| { lay.manual_breaks = manual; (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); - lay.pages.clear(); - 'outer: for ci in 0..lay.col_bands.len() { - for ri in 0..lay.row_bands.len() { - if lay.pages.len() >= MAX_PAGES { - break 'outer; - } - lay.pages.push((ri, ci)); - } - } + lay.pages = page_sequence(lay.row_bands.len(), lay.col_bands.len(), lay.settings.page_order, MAX_PAGES); }; match fit { Some((fw, fh)) => { @@ -290,6 +282,25 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid Some(lay) } +/// The pages of a sheet `rows` pages tall and `cols` pages wide as (row band, column band), in +/// print order, at most `cap` of them: down each column of pages, then over to the next (Excel's +/// default), or over each row of pages, then down to the next. +pub(crate) fn page_sequence(rows: usize, cols: usize, order: PageOrder, cap: usize) -> Vec<(usize, usize)> { + let (outer, inner) = match order { + PageOrder::DownThenOver => (cols, rows), + PageOrder::OverThenDown => (rows, cols), + }; + (0..outer) + .flat_map(|o| { + (0..inner).map(move |i| match order { + PageOrder::DownThenOver => (i, o), + PageOrder::OverThenDown => (o, i), + }) + }) + .take(cap) + .collect() +} + /// Inclusive-start/end index spans of the pages along one axis. pub(crate) type Bands = Vec<(u32, u32)>; diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 51760c4a6..f3f1c1f22 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -16,6 +16,25 @@ fn contains(hay: &[u8], needle: &str) -> bool { hay.windows(needle.len()).any(|w| w == needle.as_bytes()) } +#[test] +fn chart_data_table_prints_formatted_values_and_follows_recalculation() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 1234], ["Q2", 5678]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B2:B3", "format": "#,##0.00"})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B3", "at": "D2", "type": "column"})).unwrap(); + let (off, pages) = pdf_of(&mut s, json!({"fitToPage": true})); + assert_eq!(pages, 1); + let count = |bytes: &[u8], text: &str| String::from_utf8_lossy(bytes).matches(text).count(); + s.execute("chart.set", json!({"dataTable": true})).unwrap(); + let (on, _) = pdf_of(&mut s, json!({"fitToPage": true})); + assert_eq!(count(&on, "(1,234.00) Tj"), count(&off, "(1,234.00) Tj") + 1); + assert_eq!(count(&on, "(5,678.00) Tj"), count(&off, "(5,678.00) Tj") + 1); + s.execute("cell.set", json!({"cell": "B2", "input": "=1000+111"})).unwrap(); + let (updated, _) = pdf_of(&mut s, json!({"fitToPage": true})); + assert!(!contains(&updated, "(1,234.00) Tj")); + assert!(count(&updated, "(1,111.00) Tj") >= 2, "cell and chart data table both print the new value"); +} + #[test] fn pdf_export_keeps_safe_links_on_their_printed_pages() { let mut s = s(); @@ -148,6 +167,142 @@ fn wide_sheet_goes_down_then_over() { assert_eq!(pv["pageList"][2]["range"], "K1:O45"); } +/// The ranges of the printed pages, in print order. +fn page_ranges(s: &mut Session) -> Vec { + let pv = s.execute("file.printPreview", json!({})).unwrap(); + let list = pv["pageList"].as_array().unwrap(); + assert_eq!(pv["pages"], list.len()); + for (i, page) in list.iter().enumerate() { + assert_eq!(page["page"], i + 1); + assert_eq!(page["pageOrder"], s.doc().unwrap().wb.active().unwrap().print.page_order.id()); + } + list.iter().map(|p| p["range"].as_str().unwrap().to_string()).collect() +} + +/// The content streams of an exported PDF's pages, in page order (pages without pictures are the +/// file's only streams). +fn pdf_pages(s: &mut Session) -> Vec { + let (pdf, pages) = pdf_of(s, json!({})); + let text = String::from_utf8_lossy(&pdf); + let streams: Vec = text.split("endstream").filter(|part| part.contains("stream\n")).map(str::to_string).collect(); + assert_eq!(streams.len() as u64, pages); + streams +} + +/// The page order measured in Excel: print area A1:D4 with a row break before row 3 and a column +/// break before column C, so four pages two tall and two wide, each filled and labelled in its +/// own colour, with `Page &P of &N` in the footer. +fn two_by_two_pages() -> Session { + let mut s = s(); + for (range, color, label) in + [("A1:B2", "#FF0000", "red"), ("A3:B4", "#00FF00", "green"), ("C1:D2", "#0000FF", "blue"), ("C3:D4", "#FFFF00", "yellow")] + { + s.execute("home.fillColor", json!({"range": range, "color": color})).unwrap(); + s.execute("cell.set", json!({"cell": &range[..2], "input": label})).unwrap(); + } + s.execute("pageLayout.pageSetup", json!({"printArea": "A1:D4", "rowBreaks": [3], "colBreaks": ["C"], "footer": "Page &P of &N"})).unwrap(); + s +} + +/// The colour of each printed page, read from its fill and label; each page's footer must +/// number it in print order. +fn printed_colours(s: &mut Session) -> Vec<&'static str> { + let pages = pdf_pages(s); + let n = pages.len(); + pages + .iter() + .enumerate() + .map(|(i, page)| { + assert!(page.contains(&format!("(Page {} of {n}) Tj", i + 1)), "page {}: {page}", i + 1); + let found: Vec<&str> = [("red", "1 0 0 rg"), ("green", "0 1 0 rg"), ("blue", "0 0 1 rg"), ("yellow", "1 1 0 rg")] + .into_iter() + .filter(|(label, fill)| page.contains(&format!("({label}) Tj")) && page.contains(&format!("\n{fill}\n"))) + .map(|(label, _)| label) + .collect(); + assert_eq!(found.len(), 1, "page {}: {found:?}", i + 1); + found[0] + }) + .collect() +} + +#[test] +fn page_order_sets_the_sequence_of_the_pages_as_measured_in_excel() { + let mut s = two_by_two_pages(); + // Down, then over is the default: down the first column of pages, then the next. + assert_eq!(s.doc().unwrap().wb.active().unwrap().print.page_order, PageOrder::DownThenOver); + assert_eq!(s.execute("pageLayout.pageSetup", json!({})).unwrap()["page_order"], "downThenOver"); + let down = ["A1:B2", "A3:B4", "C1:D2", "C3:D4"]; + assert_eq!(page_ranges(&mut s), down); + assert_eq!(printed_colours(&mut s), ["red", "green", "blue", "yellow"]); + // Over, then down: across the first row of pages, then the next. + let r = s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap(); + assert_eq!(r["page_order"], "overThenDown"); + let over = ["A1:B2", "C1:D2", "A3:B4", "C3:D4"]; + assert_eq!(page_ranges(&mut s), over); + assert_eq!(printed_colours(&mut s), ["red", "blue", "green", "yellow"]); + assert_eq!(s.execute("file.printPreview", json!({"page": 2})).unwrap()["page"]["range"], "C1:D2"); + // One undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(page_ranges(&mut s), down); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(page_ranges(&mut s), over); + // Anything but the two orders is refused and changes nothing, even next to a good value. + for bad in [json!("sideways"), json!("OverThenDown"), json!(""), json!(2), json!(null), json!(true), json!(["downThenOver"])] { + let e = s.execute("pageLayout.pageSetup", json!({"pageOrder": bad, "orientation": "landscape"})).unwrap_err(); + assert!(e.to_string().contains("pageOrder"), "{bad}: {e}"); + } + let pr = &s.doc().unwrap().wb.active().unwrap().print; + assert_eq!((pr.page_order, pr.orientation), (PageOrder::OverThenDown, Orientation::Portrait)); + assert!(s.take_ui_requests().is_empty(), "a programmatic call never opens a dialog"); + // First-page and odd/even headers go by the printed sequence too. + s.execute( + "pageLayout.headerFooter", + json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}), + ) + .unwrap(); + let pages = pdf_pages(&mut s); + for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "blue"), ("Odd 3", "green"), ("Even 4", "yellow")]) { + assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}"); + } + s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap(); + assert_eq!(page_ranges(&mut s), down); + let pages = pdf_pages(&mut s); + for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "green"), ("Odd 3", "blue"), ("Even 4", "yellow")]) { + assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}"); + } +} + +#[test] +fn page_order_without_breaks_and_with_print_titles() { + let mut s = s(); + // Every cell shows its own address: 60 rows and 15 columns are two pages tall and two wide. + let rows: Vec> = (1..=60).map(|r| (0..15).map(|c| json!(format!("{}{r}", gridcraft_core::col_to_letters(c)))).collect()).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + for titles in [false, true] { + if titles { + s.execute("pageLayout.pageSetup", json!({"titleRows": "1:1", "titleCols": "A:A"})).unwrap(); + } + s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap(); + let down = page_ranges(&mut s); + if !titles { + assert_eq!(down, ["A1:J45", "A46:J60", "K1:O45", "K46:O60"]); + } + s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap(); + let over = page_ranges(&mut s); + assert_eq!(over, [down[0].clone(), down[2].clone(), down[1].clone(), down[3].clone()], "titles: {titles}"); + // Each printed page starts with its own top-left cell; the title row and column (A1 is in + // both) repeat on every page in either order. + for (order, ranges) in [("overThenDown", &over), ("downThenOver", &down)] { + s.execute("pageLayout.pageSetup", json!({"pageOrder": order})).unwrap(); + for (i, (page, range)) in pdf_pages(&mut s).iter().zip(ranges.iter()).enumerate() { + let top_left = range.split(':').next().unwrap(); + assert!(page.contains(&format!("({top_left}) Tj")), "{order} page {}: no {top_left}", i + 1); + assert_eq!(page.contains("(A1) Tj"), titles || i == 0, "{order} page {}, titles: {titles}", i + 1); + } + } + } +} + #[test] fn first_and_even_pages_print_their_own_headers() { let mut s = s(); @@ -473,6 +628,11 @@ fn hostile_settings_never_panic_and_cap_pages() { assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64); let r = s.execute("file.exportPdf", json!({})).unwrap(); assert_eq!(r["pages"], MAX_PAGES); + // Over, then down stops at the cap as well, along the first row of pages. + s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64); + assert_eq!(pv["pageList"][1]["rows"], pv["pageList"][0]["rows"], "the second page is beside the first"); s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 1, "titleRows": "1:1048576", "titleCols": "A:XFD", "headings": true})) .unwrap(); let pv = s.execute("file.printPreview", json!({})).unwrap(); diff --git a/crates/engine/src/paper.rs b/crates/engine/src/paper.rs index 0800f4fc7..99981fc8c 100644 --- a/crates/engine/src/paper.rs +++ b/crates/engine/src/paper.rs @@ -10,7 +10,7 @@ //! `(gap + j·(page_w + gap), gap + i·(page_h + gap))`, and its cells start at its top-left margin. use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; -use gridcraft_model::{LineIndex, Sheet, Workbook}; +use gridcraft_model::{LineIndex, PageOrder, Sheet, Workbook}; use crate::cmd::print::{self, PX_TO_PT, PrintOptions}; @@ -45,6 +45,8 @@ pub struct PageGrid { /// Bands holding printed data: `[first, last]` row bands and column bands. pub data_rows: (usize, usize), pub data_cols: (usize, usize), + /// The sheet's page order, which numbers the printed pages. + pub page_order: PageOrder, ri: LineIndex, ci: LineIndex, } @@ -96,6 +98,7 @@ impl PageGrid { col_bands, data_rows, data_cols, + page_order: printed.settings.page_order, ri: LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS), ci: LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS), }) @@ -206,14 +209,14 @@ impl PageGrid { pub fn is_data_page(&self, i: usize, j: usize) -> bool { (self.data_rows.0..=self.data_rows.1).contains(&i) && (self.data_cols.0..=self.data_cols.1).contains(&j) } - /// Printed page number of page `(i, j)` (down, then over) and the printed page count. + /// Printed page number of page `(i, j)` (in the sheet's page order) and the printed page count. pub fn page_number(&self, i: usize, j: usize) -> Option<(usize, usize)> { if !self.is_data_page(i, j) { return None; } let rows = self.data_rows.1 - self.data_rows.0 + 1; let cols = self.data_cols.1 - self.data_cols.0 + 1; - Some(((j - self.data_cols.0) * rows + (i - self.data_rows.0) + 1, rows * cols)) + Some((self.page_order.index(i - self.data_rows.0, j - self.data_cols.0, rows, cols) + 1, rows * cols)) } /// Page rows and columns overlapping the layout rectangle `[x0, x1] × [y0, y1]`. @@ -312,6 +315,21 @@ mod tests { assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0)); } + #[test] + fn page_numbers_follow_the_page_order() { + // Two pages tall and two wide, as printed; the pages beyond are "Click to add data" pages. + let mut wb = wb_with(60, 15); + let numbers = |wb: &Workbook| { + let g = PageGrid::build(wb, 0, 200, 60).unwrap(); + assert_eq!((g.data_rows, g.data_cols), ((0, 1), (0, 1))); + assert_eq!((g.page_number(2, 0), g.page_number(0, 2)), (None, None)); + [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(i, j)| g.page_number(i, j).unwrap()) + }; + assert_eq!(numbers(&wb), [(1, 4), (2, 4), (3, 4), (4, 4)]); + wb.sheet_mut(0).unwrap().print.page_order = PageOrder::OverThenDown; + assert_eq!(numbers(&wb), [(1, 4), (3, 4), (2, 4), (4, 4)]); + } + #[test] fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() { let mut wb = wb_with(10, 3); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 99ebd42b1..a9cd7cbfe 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -30,5 +30,6 @@ mod sparse_clear_invalidation; mod stored_list_shape; mod table_resize_overlap; mod table_total_row_last_row; +mod trace_error; mod workbook_author; mod wrap_row_height; diff --git a/crates/engine/tests/it/trace_error.rs b/crates/engine/tests/it/trace_error.rs new file mode 100644 index 000000000..7c191f7d7 --- /dev/null +++ b/crates/engine/tests/it/trace_error.rs @@ -0,0 +1,233 @@ +//! Formulas › Formula Auditing › Error Checking › Trace Error: from a cell showing an error, the +//! first reference that shows the same error, back to the cell the error starts in, with arrows +//! along the way and from the source's own references into it. The sheet and the expected +//! sources and arrows are the ones measured in Excel. + +use gridcraft_engine::Session; +use serde_json::{Value as Json, json}; + +/// The measured sheet. A4 is a typed `#N/A`; I1's own formula gives `#REF!`. +fn measured() -> Session { + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [ + ("A1", "=1/0"), + ("A2", "5"), + ("B1", "=A1+A2"), + ("C1", "=B1*2"), + ("D1", "=SUM(C1,A2)"), + ("A4", "#N/A"), + ("B4", "=A4"), + ("C4", "=B4&\"x\""), + ("A6", "=SQRT(-1)"), + ("A7", "3"), + ("B6", "=A6+A7"), + ("E1", "10"), + ("E2", "=E1*2"), + ("F1", "=A1+A4"), + ("F2", "=A4+A1"), + ("G1", "=IFERROR(A1,0)"), + ("G2", "=G1+B1"), + ("H1", "0"), + ("H2", "=10/H1"), + ("H3", "=H2+E1"), + ("H4", "=H3*1"), + ("I1", "=#REF!"), + ("I2", "=I1+1"), + ("J1", "=XLOOKUP(99,A2:A3,A2:A3)"), + ("J2", "=J1"), + ("A9", "=A10"), + ("A10", "=1/0"), + ("B9", "=SUM(A9:A10)"), + ("C9", "=B9"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s +} + +fn trace(s: &mut Session, cell: &str) -> Json { + s.execute("formulas.traceError", json!({"cell": cell})).unwrap() +} + +/// An end of an arrow as `A1`, or `Sheet2!A1` off Sheet1. +fn end(v: &Json) -> String { + let at = v["range"].as_str().or(v["cell"].as_str()).unwrap(); + match v["sheet"].as_str().unwrap() { + "Sheet1" => at.to_string(), + sheet => format!("{sheet}!{at}"), + } +} + +/// The arrows as `from → to`, with ` (precedent)` after the source's own references. +fn arrows(r: &Json) -> Vec { + r["arrows"] + .as_array() + .unwrap() + .iter() + .map(|a| { + let kind = match a["kind"].as_str().unwrap() { + "error" => "", + "precedent" => " (precedent)", + k => panic!("unknown kind {k}"), + }; + format!("{} → {}{kind}", end(&a["from"]), end(&a["to"])) + }) + .collect() +} + +#[test] +fn trace_error_finds_the_measured_sources_and_arrows() { + let mut s = measured(); + for (cell, error, source, expected) in [ + ("C1", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → C1"]), + ("D1", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → C1", "C1 → D1"]), + ("B1", "#DIV/0!", "A1", vec!["A1 → B1"]), + ("A1", "#DIV/0!", "A1", vec![]), + ("A6", "#NUM!", "A6", vec![]), + ("C4", "#N/A", "A4", vec!["A4 → B4", "B4 → C4"]), + // Two errors: the first reference showing the cell's own error. + ("F1", "#DIV/0!", "A1", vec!["A1 → F1"]), + ("F2", "#N/A", "A4", vec!["A4 → F2"]), + // IFERROR in G1 hides the error, so the error comes through B1. + ("G2", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → G2"]), + // H1 feeds the source H2 without an error of its own; E1 feeds H3 and has no arrow. + ("H4", "#DIV/0!", "H2", vec!["H1 → H2 (precedent)", "H2 → H3", "H3 → H4"]), + ("H3", "#DIV/0!", "H2", vec!["H1 → H2 (precedent)", "H2 → H3"]), + // XLOOKUP's two A2:A3 arguments make one range arrow into the source. + ("J2", "#N/A", "J1", vec!["A2:A3 → J1 (precedent)", "J1 → J2"]), + // In A9:A10 the first cell showing the error is A9, itself fed by A10. + ("C9", "#DIV/0!", "A10", vec!["A10 → A9", "A9 → B9", "B9 → C9"]), + ("I2", "#REF!", "I1", vec!["I1 → I2"]), + ] { + let r = trace(&mut s, cell); + assert_eq!((r["sheet"].as_str(), r["cell"].as_str()), (Some("Sheet1"), Some(cell))); + assert_eq!(r["error"], error, "{cell}"); + assert_eq!(end(&r["source"]), source, "{cell}"); + assert_eq!(arrows(&r), expected, "{cell}"); + assert_eq!(r["truncated"], false, "{cell}"); + } +} + +#[test] +fn a_cell_without_an_error_has_no_source_and_no_arrows() { + let mut s = measured(); + for cell in ["E2", "A2", "Z99", "G1"] { + let r = trace(&mut s, cell); + assert_eq!(r, json!({"sheet": "Sheet1", "cell": cell, "error": null, "source": null, "arrows": [], "truncated": false})); + } + // Without `cell`, the active cell. + s.execute("selection.set", json!({"range": "C1"})).unwrap(); + let r = s.execute("formulas.traceError", json!({})).unwrap(); + assert_eq!((r["cell"].as_str(), arrows(&r).len()), (Some("C1"), 2)); +} + +#[test] +fn tracing_changes_nothing_and_twice_gives_the_same_arrows() { + let mut s = measured(); + let (wb, undo) = (s.doc().unwrap().wb.clone(), s.doc().unwrap().undo.len()); + let first = trace(&mut s, "C1"); + assert_eq!(trace(&mut s, "C1"), first); + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&d.wb, &wb), "the workbook is untouched"); + assert_eq!(d.undo.len(), undo, "no undo step"); +} + +#[test] +fn a_cell_on_another_sheet_is_refused_and_bad_params_fail() { + let mut s = measured(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + for cell in [json!("Two!A1"), json!("not a cell"), json!(5)] { + assert!(s.execute("formulas.traceError", json!({"cell": cell})).is_err(), "{cell}"); + } + assert!(s.execute("formulas.traceError", json!({"cell": "Sheet1!C1"})).is_ok()); +} + +#[test] +fn the_error_is_followed_across_sheets() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Data 2"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=1/B1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "0"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A1"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "='Data 2'!C1*2"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=B2"})).unwrap(); + let r = trace(&mut s, "C2"); + assert_eq!(end(&r["source"]), "Data 2!A1"); + assert_eq!(arrows(&r), ["Data 2!B1 → Data 2!A1 (precedent)", "Data 2!A1 → Data 2!C1", "Data 2!C1 → B2", "B2 → C2"]); +} + +#[test] +fn only_the_cells_own_error_is_followed() { + let mut s = measured(); + // K1 shows #DIV/0!; A4 shows #N/A, another error, so K1 is the source and A4 only feeds it. + s.execute("cell.set", json!({"cell": "K1", "input": "=IF(ISNA(A4),1/0,0)"})).unwrap(); + s.execute("cell.set", json!({"cell": "K2", "input": "=K1+F2"})).unwrap(); + let r = trace(&mut s, "K2"); + assert_eq!(r["error"], "#DIV/0!"); + assert_eq!(end(&r["source"]), "K1"); + assert_eq!(arrows(&r), ["A4 → K1 (precedent)", "K1 → K2"]); +} + +#[test] +fn a_spilled_error_leads_to_the_references_of_its_array_formula() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 1], [2, 0]]})).unwrap(); + // D1 spills 1 and #DIV/0! into D1:D2. + s.execute("cell.set", json!({"cell": "D1", "input": "=A1:A2/B1:B2"})).unwrap(); + s.execute("cell.set", json!({"cell": "E1", "input": "=SUM(D1:D2)"})).unwrap(); + let r = trace(&mut s, "E1"); + assert_eq!(end(&r["source"]), "D2", "{r}"); + assert_eq!(arrows(&r), ["A1:A2 → D2 (precedent)", "B1:B2 → D2 (precedent)", "D2 → E1"]); +} + +#[test] +fn a_circular_reference_ends_the_walk() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=B1+1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A1+1"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=C1"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "=B1"})).unwrap(); + let r = trace(&mut s, "D1"); + assert!(r["error"].is_string(), "{r}"); + assert_eq!(end(&r["source"]), "A1"); + assert_eq!(arrows(&r), ["B1 → A1 (precedent)", "A1 → B1", "B1 → D1"]); + // A cell that reads itself is its own source. + let r = trace(&mut s, "C1"); + assert_eq!(end(&r["source"]), "C1"); + assert_eq!(arrows(&r), ["C1 → C1 (precedent)"]); +} + +#[test] +fn a_long_chain_is_walked_to_its_source_and_a_longer_one_stops_at_the_limit() { + // On a 1 MiB stack, as on the desktop app's main thread: the walk must not recurse. + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let mut s = Session::new(); + s.new_workbook(); + let rows = 10_002; + let formulas: Vec<[String; 1]> = (1..=rows).map(|r| [if r == 1 { "=1/0".to_string() } else { format!("=A{}", r - 1) }]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": formulas})).unwrap(); + // 10,000 links, the most Trace Error walks: it reaches A1. + let r = trace(&mut s, "A10001"); + assert_eq!((end(&r["source"]), r["truncated"].clone()), ("A1".to_string(), json!(false))); + let list = arrows(&r); + assert_eq!(list.len(), 10_000); + assert_eq!((list.first().map(String::as_str), list.last().map(String::as_str)), (Some("A1 → A2"), Some("A10000 → A10001"))); + // One more and the walk stops short of the source. + let r = trace(&mut s, "A10002"); + assert_eq!((r["source"].clone(), r["truncated"].clone()), (json!(null), json!(true))); + let list = arrows(&r); + assert_eq!(list.len(), 10_000); + assert_eq!(list.first().map(String::as_str), Some("A2 → A3"), "no arrows into a cell that isn't the source"); + }) + .unwrap() + .join() + .unwrap(); +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 2394319c3..4626c0c69 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -10,7 +10,7 @@ mod pivot; mod print; pub use pivot::{PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue}; -pub use print::{EditRange, HfPage, LineInfo, Orientation, PrintSettings, SheetProtection, password_hash}; +pub use print::{EditRange, HfPage, LineInfo, Orientation, PageOrder, PrintSettings, SheetProtection, password_hash}; // ---------------------------------------------------------------- tables @@ -540,6 +540,22 @@ impl Trendline { } } +/// A chart's data table (`c:dTable`), independent of the chart legend. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ChartDataTable { + pub show_keys: bool, + pub horizontal_border: bool, + pub vertical_border: bool, + pub outline: bool, +} + +impl Default for ChartDataTable { + fn default() -> Self { + Self { show_keys: true, horizontal_border: true, vertical_border: true, outline: true } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Chart { pub id: u32, @@ -549,6 +565,8 @@ pub struct Chart { pub series: Vec, pub legend: LegendPos, pub data_labels: bool, + #[serde(default)] + pub data_table: Option, pub gridlines: bool, pub style: u32, pub x_title: Option, @@ -562,6 +580,30 @@ pub struct Chart { pub pivot: Option, } +impl Chart { + /// Data tables belong to category-axis column, bar, line and area charts. + pub fn supports_data_table(&self) -> bool { + let eligible = |kind| { + matches!( + kind, + ChartKind::ColumnClustered + | ChartKind::ColumnStacked + | ChartKind::ColumnStacked100 + | ChartKind::BarClustered + | ChartKind::BarStacked + | ChartKind::BarStacked100 + | ChartKind::Line + | ChartKind::LineMarkers + | ChartKind::LineStacked + | ChartKind::Area + | ChartKind::AreaStacked + | ChartKind::AreaStacked100 + ) + }; + eligible(self.kind) || (self.kind == ChartKind::Combo && self.series.iter().all(|s| eligible(s.kind.unwrap_or(ChartKind::Line)))) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Image { pub id: u32, diff --git a/crates/model/src/features/print.rs b/crates/model/src/features/print.rs index cbcaf3e55..694c2ac6d 100644 --- a/crates/model/src/features/print.rs +++ b/crates/model/src/features/print.rs @@ -49,12 +49,52 @@ pub struct PrintSettings { pub hf_scale_with_doc: bool, #[serde(default = "yes")] pub hf_align_with_margins: bool, + /// Page Setup › Sheet › Page order: the sequence pages are numbered and printed in when the + /// sheet is more than one page wide and tall. + #[serde(default)] + pub page_order: PageOrder, } fn yes() -> bool { true } +/// The sequence of a sheet's pages (XLSX `pageSetup pageOrder`, ECMA-376 `ST_PageOrder`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PageOrder { + /// Down the first column of pages, then down the next one (Excel's default). + #[default] + DownThenOver, + /// Across the first row of pages, then across the next one. + OverThenDown, +} + +impl PageOrder { + /// The id `pageLayout.pageSetup` takes and XLSX writes. + pub fn id(self) -> &'static str { + match self { + PageOrder::DownThenOver => "downThenOver", + PageOrder::OverThenDown => "overThenDown", + } + } + pub fn parse(id: &str) -> Option { + match id { + "downThenOver" => Some(PageOrder::DownThenOver), + "overThenDown" => Some(PageOrder::OverThenDown), + _ => None, + } + } + /// The 0-based place in the printed sequence of the page in page row `row` and page column + /// `col`, on a sheet `rows` pages tall and `cols` pages wide. + pub fn index(self, row: usize, col: usize, rows: usize, cols: usize) -> usize { + match self { + PageOrder::DownThenOver => col.saturating_mul(rows).saturating_add(row), + PageOrder::OverThenDown => row.saturating_mul(cols).saturating_add(col), + } + } +} + /// Which header/footer pair a printed page uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HfPage { @@ -146,6 +186,7 @@ impl Default for PrintSettings { even_footer: String::new(), hf_scale_with_doc: true, hf_align_with_margins: true, + page_order: PageOrder::DownThenOver, } } } @@ -215,3 +256,29 @@ pub struct LineInfo { #[serde(default)] pub custom: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_order_numbers_pages_and_old_settings_load_down_then_over() { + // Two pages tall, three wide: (row, col) → place in the printed sequence. + let place = |order: PageOrder| [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)].map(|(r, c)| order.index(r, c, 2, 3)); + assert_eq!(place(PageOrder::DownThenOver), [0, 1, 2, 3, 4, 5]); + assert_eq!(place(PageOrder::OverThenDown), [0, 3, 1, 4, 2, 5]); + assert_eq!(PageOrder::OverThenDown.index(usize::MAX, usize::MAX, usize::MAX, usize::MAX), usize::MAX); + for order in [PageOrder::DownThenOver, PageOrder::OverThenDown] { + assert_eq!(PageOrder::parse(order.id()), Some(order)); + } + assert_eq!(PageOrder::parse("OverThenDown"), None); + // Saved under the same id the command takes; settings saved before the field existed load + // with Excel's default. + let mut p = PrintSettings { page_order: PageOrder::OverThenDown, ..Default::default() }; + let mut saved = serde_json::to_value(&p).unwrap(); + assert_eq!(saved["page_order"], "overThenDown"); + assert!(saved.as_object_mut().unwrap().remove("page_order").is_some()); + p = serde_json::from_value(saved).unwrap(); + assert_eq!(p, PrintSettings::default()); + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c7dcde0ff..7855700ea 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -46,8 +46,20 @@ impl SheetApp { if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } + // The macro editor's unsaved draft is part of its workbook: closing the workbook asks + // about it first, and goes on once the editor has closed. A close asked while it is + // asking waits for the answer, or is refused when it asks for a different close. + if matches!(id, "file.close" | "window.close") + && let Some(r) = crate::macro_editor::ask_before_closing(self, id, ¶ms) + { + return r; + } let before = self.session.active_window(); + let zoom_from = matches!(id, "view.zoom" | "view.zoom100" | "view.zoomToSelection").then(|| crate::grid::Trace::key(self)); let r = self.session.run(id, params); + if let Some(from) = zoom_from { + crate::grid::Trace::keep_through_zoom(self, from); + } if id.starts_with("file.") && self.session.active_window() != before { // File › New or Open from the Backstage view: the window it was in goes back to its sheet. self.backstage = None; diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index ffa230c34..7f364829f 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -81,8 +81,9 @@ impl SheetApp { true } - /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks - /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, lets the + /// macro step editor ask about its unsaved steps, then asks about each unsaved workbook in its + /// window; Cancel on any of them stops the quit. A draft its /// workbook refuses stops the quit too, with its window in front and the reason shown. pub fn request_quit(&mut self, ctx: &egui::Context) { while let Some(window) = self.parked_window_with_draft() { @@ -94,6 +95,16 @@ impl SheetApp { if self.settle_drafts().is_err() { return; } + // The macro step editor asks about its own unsaved steps and debug run first, as for the + // window's close button, in the window it is shown in; once it has closed, the quit goes + // on, and Cancel stops it. + if let Some(window) = self.window_with_dialog("macroEditor") { + if self.ui_window() != Some(window) { + self.enter_window(window); + } + crate::macro_editor::request_close(self, true); + return; + } if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { // Already asking about a workbook (File › Close): answering it goes on to quit. d.values.insert("quit".into(), json!(true)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9b04f1384..d791b2851 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -551,14 +551,20 @@ impl Dialog { }, ), "pageSetup" => { - // Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged. - let m = app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or([0.7, 0.7, 0.75, 0.75, 0.3, 0.3]); + // Opens on the active sheet's saved margins and page order, so OK without edits + // rewrites them unchanged. One `pageLayout.pageSetup` sets them all: one undo step. + let (margins, order) = app + .session + .active() + .and_then(|d| d.wb.active()) + .map(|s| (s.print.margins, s.print.page_order)) + .unwrap_or(([0.7, 0.7, 0.75, 0.75, 0.3, 0.3], Default::default())); // Round off f32 noise so the form shows 0.1, not 0.10000000149. - let [left, right, top, bottom, header, footer] = m.map(|v| (f64::from(v) * 1e4).round() / 1e4); + let [left, right, top, bottom, header, footer] = margins.map(|v| (f64::from(v) * 1e4).round() / 1e4); Dialog::form( "pageSetup", "Page Setup", - "pageLayout.margins", + "pageLayout.pageSetup", vec![ Number { key: "left", label: "Left margin (in):" }, Number { key: "right", label: "Right margin (in):" }, @@ -566,8 +572,13 @@ impl Dialog { Number { key: "bottom", label: "Bottom margin (in):" }, Number { key: "header", label: "Header (in):" }, Number { key: "footer", label: "Footer (in):" }, + Choice { + key: "pageOrder", + label: "Page order", + options: vec![("downThenOver", "Down, then over"), ("overThenDown", "Over, then down")], + }, ], - json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer}), + json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer, "pageOrder": order.id()}), ) } "textToColumns" => Dialog::form( diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs index 40e4028cb..1e27d0bd4 100644 --- a/crates/ui-egui/src/dialogs/analysis.rs +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -32,7 +32,7 @@ pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, c } /// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). -fn toolpak_fields(tool: &str) -> Vec { +fn toolpak_fields(tool: &str, values: &Map) -> Vec { use Field::*; let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; let labels = || Check { key: "labels", label: "Labels in first row" }; @@ -57,6 +57,17 @@ fn toolpak_fields(tool: &str) -> Vec { ], "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], + // With Labels ticked this tool reads row names from the first column as well as column + // names from the first row, so the caption says both. + "anovaTwoFactorWithout" => { + vec![range(), Check { key: "labels", label: "Labels in first row and first column" }, Text { key: "alpha", label: "Alpha:" }] + } + "anovaTwoFactorWith" => vec![ + Note("The input range holds column labels in its first row and sample labels in its first column.".into()), + range(), + Text { key: "rowsPerSample", label: "Rows per sample:" }, + Text { key: "alpha", label: "Alpha:" }, + ], "regression" => vec![ Text { key: "inputY", label: "Input Y Range:" }, Text { key: "inputX", label: "Input X Range:" }, @@ -70,6 +81,65 @@ fn toolpak_fields(tool: &str) -> Vec { "movingAverage" => { vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] } + "randomNumberGeneration" => { + let dist = values.get("distribution").and_then(Json::as_str).unwrap_or("uniform"); + let mut v = vec![Text { key: "variables", label: "Number of Variables:" }]; + // A patterned sequence sets its own length, so it has no count to type. + if dist != "patterned" { + v.push(Text { key: "count", label: "Number of Random Numbers:" }); + } + v.push(Choice { + key: "distribution", + label: "Distribution:", + options: vec![ + ("uniform", "Uniform"), + ("normal", "Normal"), + ("bernoulli", "Bernoulli"), + ("binomial", "Binomial"), + ("poisson", "Poisson"), + ("patterned", "Patterned"), + ("discrete", "Discrete"), + ], + }); + v.extend(match dist { + "normal" => vec![Text { key: "mean", label: "Mean:" }, Text { key: "standardDeviation", label: "Standard deviation:" }], + "bernoulli" => vec![Text { key: "p", label: "p Value:" }], + "binomial" => vec![Text { key: "p", label: "p Value:" }, Text { key: "trials", label: "Number of trials:" }], + "poisson" => vec![Text { key: "lambda", label: "Lambda:" }], + "patterned" => vec![ + Note("Each number is repeated, then the whole sequence; together they set how many values are written.".into()), + Text { key: "from", label: "From:" }, + Text { key: "to", label: "To:" }, + Text { key: "step", label: "In steps of:" }, + Text { key: "repeatEach", label: "Repeating each number (times):" }, + Text { key: "repeatSequence", label: "Repeating each sequence (times):" }, + ], + "discrete" => vec![Text { key: "valueRange", label: "Value and Probability Input Range:" }], + _ => vec![Text { key: "from", label: "Between:" }, Text { key: "to", label: "And:" }], + }); + // A patterned sequence draws nothing, so it has no seed. + if dist != "patterned" { + v.push(Text { key: "seed", label: "Random Seed (blank for a new sequence):" }); + } + v + } + "sampling" => { + let mut v = vec![ + range(), + labels(), + Choice { key: "method", label: "Sampling Method:", options: vec![("periodic", "Periodic"), ("random", "Random")] }, + ]; + // Periodic sampling takes every period-th value and draws nothing, so only random + // sampling has a seed. + if values.get("method").and_then(Json::as_str) == Some("random") { + v.push(Text { key: "samples", label: "Number of Samples:" }); + v.push(Text { key: "seed", label: "Random Seed (blank for a new sequence):" }); + } else { + v.push(Text { key: "period", label: "Period:" }); + } + v + } + "fourierAnalysis" => vec![range(), labels(), Check { key: "inverse", label: "Inverse" }], "exponentialSmoothing" => { vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] } @@ -78,6 +148,17 @@ fn toolpak_fields(tool: &str) -> Vec { v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); v } + "zTest" => { + let mut v = two(); + v.extend([ + Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, + Text { key: "variance1", label: "Variable 1 Variance (known):" }, + Text { key: "variance2", label: "Variable 2 Variance (known):" }, + labels(), + Text { key: "alpha", label: "Alpha:" }, + ]); + v + } _ => { let mut v = two(); v.extend([ @@ -96,8 +177,32 @@ pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map { "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", + "rowsPerSample": "", "variance1": "", "variance2": "", "inverse": false, "outputTo": "newSheet", "output": "", "newSheet": "", }); + // Random Number Generation and Sampling. + for (k, x) in [ + ("variables", "1"), + ("count", ""), + ("distribution", "uniform"), + ("from", "0"), + ("to", "1"), + ("mean", "0"), + ("standardDeviation", "1"), + ("p", "0.5"), + ("trials", "10"), + ("lambda", "1"), + ("step", "1"), + ("repeatEach", "1"), + ("repeatSequence", "1"), + ("valueRange", ""), + ("seed", ""), + ("method", "periodic"), + ("period", ""), + ("samples", ""), + ] { + v[k] = json!(x); + } if tool == "regression" { v["confidence"] = json!("95"); v["inputY"] = json!(sel); @@ -105,6 +210,10 @@ pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map { v.as_object().cloned().unwrap_or_default() } +/// Height a ToolPak form needs besides the tool's own options: title bar, output options, OK +/// and the window's margins. +const TOOLPAK_CHROME: f32 = 240.0; + /// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` /// (through the dialog's command); a report that would replace data asks first. pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { @@ -149,8 +258,11 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di return; }; d.title = label.to_string(); - d.fields = toolpak_fields(&tool); - form_fields(ui, d); + d.fields = toolpak_fields(&tool, &d.values); + // The tool's options scroll in a short window (the patterned Random Number Generation form + // is taller than 600 points); the output options and OK below always stay in view. + let room = (ui.ctx().content_rect().height() - TOOLPAK_CHROME).max(120.0); + egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(ui, d)); ui.separator(); ui.label(egui::RichText::new("Output options").strong()); let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 008b23951..b42e96abe 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -12,6 +12,7 @@ use super::paste::{paste_special, paste_special_shortcuts}; use super::spelling::spelling; use super::zoom::zoom; use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2}; +use gridcraft_engine::model::PageOrder; /// Builds the command params from form values for dialogs that need translation. /// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. @@ -105,6 +106,15 @@ pub(crate) fn build_params(d: &Dialog) -> Json { } } } + "pageSetup" => { + // A margin dragged below zero is zero, as the Margins command makes it (Page Setup + // refuses negative margins from other callers). + for key in ["left", "right", "top", "bottom", "header", "footer"] { + if let Some(inches) = d.values.get(key).and_then(Json::as_f64) { + v[key] = json!(inches.max(0.0)); + } + } + } _ => {} } v @@ -579,10 +589,22 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { .response .labelled_by(field_label.id); } else { - for (val, text) in options { - if ui.radio(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); + let radios = |ui: &mut egui::Ui, d: &mut Dialog| { + for (val, text) in &options { + if ui.radio(cur == *val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } } + }; + if key == "pageOrder" { + // Page Setup pictures the order beside its choices. + ui.horizontal(|ui| { + ui.vertical(|ui| radios(ui, d)); + ui.add_space(24.0); + page_order_preview(ui, PageOrder::parse(&cur).unwrap_or_default()); + }); + } else { + radios(ui, d); } } }); @@ -604,3 +626,30 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { } }); } + +/// Page Setup's picture of a page order: four pages, two tall and two wide, numbered in the +/// order they print, with a line through them from the first to the last. +fn page_order_preview(ui: &mut egui::Ui, order: PageOrder) { + let t = crate::theme::Tokens::get(ui.ctx()); + let (page, gap) = (vec2(22.0, 28.0), 8.0); + let (rect, _) = ui.allocate_exact_size(vec2(page.x * 2.0 + gap, page.y * 2.0 + gap), egui::Sense::hover()); + let p = ui.painter(); + let mut path = [egui::Pos2::ZERO; 4]; + for (row, col) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let r = egui::Rect::from_min_size(rect.min + vec2(col as f32 * (page.x + gap), row as f32 * (page.y + gap)), page); + p.rect_filled(r, 1.0, t.paper); + p.rect_stroke(r, 1.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + let n = order.index(row, col, 2, 2); + p.text(r.left_top() + vec2(4.0, 2.0), egui::Align2::LEFT_TOP, (n + 1).to_string(), crate::theme::ui_font(9.0), t.text); + if let Some(at) = path.get_mut(n) { + *at = r.center() + vec2(2.0, 3.0); + } + } + let stroke = egui::Stroke::new(1.5, t.accent); + p.add(egui::Shape::line(path.to_vec(), stroke)); + // An arrowhead on the last page. + let (from, to) = (path[2], path[3]); + let d = (to - from).normalized(); + let n = vec2(-d.y, d.x); + p.add(egui::Shape::convex_polygon(vec![to, to - d * 6.0 + n * 3.5, to - d * 6.0 - n * 3.5], t.accent, egui::Stroke::NONE)); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c5e1dd473..554a8cbea 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -153,23 +153,74 @@ pub struct FillTag { pub undo_len: usize, } -/// Trace Precedents / Trace Dependents arrows: they stay on the cell they were traced from, on its -/// sheet, while the selection moves. They go away when anything changes the workbook -/// (`revision`): Excel removes them on edits to the traced formula and on inserted, deleted or -/// moved cells, this also on a new value in a precedent. +/// Trace Precedents, Trace Dependents and Trace Error arrows: they stay on the cells they were +/// traced from while the selection moves. They go away when anything but a zoom changes the +/// workbook (`revision`): Excel removes them on edits to the traced formula and on inserted, +/// deleted or moved cells, this also on a new value in a precedent. pub struct Trace { pub uid: u64, pub revision: u64, - pub sheet: usize, - pub cell: CellRef, - /// The command's result: `{"sheet", "range"}` precedents or `{"sheet", "cell"}` dependents. - pub items: serde_json::Value, + pub arrows: Vec, +} + +/// One trace arrow, from a cell or range (boxed) to a cell, each on its sheet. An arrow is drawn +/// on a sheet that holds both its ends. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TraceArrow { + pub from: (usize, RangeRef), + pub to: (usize, RangeRef), + /// A link between two cells showing the error Trace Error follows, drawn in the error colour. + pub error: bool, } impl Trace { - /// A trace of the active cell, just run. - pub fn new(app: &SheetApp, items: serde_json::Value) -> Option { - app.session.active().map(|d| Trace { uid: d.uid, revision: d.revision, sheet: d.wb.active_sheet(), cell: d.selection.active, items }) + /// The arrows of a trace of the active cell, from the command's result: `{"sheet", "range"}` + /// precedents (Trace Precedents), `{"sheet", "cell"}` dependents (Trace Dependents), or + /// `{"arrows": [{"from", "to", "kind"}]}` (Trace Error). Ends on a sheet the workbook no + /// longer has are left out. + pub fn new(app: &SheetApp, result: serde_json::Value) -> Option { + let d = app.session.active()?; + let traced = (d.wb.active_sheet(), RangeRef::new(d.selection.active, d.selection.active)); + let end = |v: &serde_json::Value| { + let sheet = match v["sheet"].as_str() { + Some(name) => d.wb.sheet_index(name)?, + None => traced.0, + }; + Some((sheet, RangeRef::parse(v["range"].as_str().or(v["cell"].as_str())?)?)) + }; + let arrows = match result.get("arrows").and_then(|a| a.as_array()) { + Some(list) => { + list.iter().filter_map(|a| Some(TraceArrow { from: end(&a["from"])?, to: end(&a["to"])?, error: a["kind"] == "error" })).collect() + } + None => result + .as_array() + .into_iter() + .flatten() + .filter_map(|it| { + let (from, to) = if it.get("range").is_some() { (end(it)?, traced) } else { (traced, end(it)?) }; + Some(TraceArrow { from, to, error: false }) + }) + .collect(), + }; + Some(Trace { uid: d.uid, revision: d.revision, arrows }) + } + + /// The active document's uid and revision, to hand [`Trace::keep_through_zoom`] after a zoom. + pub fn key(app: &SheetApp) -> Option<(u64, u64)> { + app.session.active().map(|d| (d.uid, d.revision)) + } + + /// Zooming changes the workbook (its revision) but moves no cell: arrows that were shown + /// before a zoom stay after it, as in Excel (#3097). `before` is [`Trace::key`] from then. + pub fn keep_through_zoom(app: &mut SheetApp, before: Option<(u64, u64)>) { + let now = Trace::key(app); + if let Some(t) = app.grid.trace.as_mut() + && before == Some((t.uid, t.revision)) + && let Some((uid, revision)) = now + && uid == t.uid + { + t.revision = revision; + } } } diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index 2e79ee165..8b8da8051 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -1,4 +1,3 @@ -#[cfg(test)] use super::Trace; use super::cursor::update_cursor; use super::editor::{in_cell_editor, list_picker, validation_arrow}; @@ -358,7 +357,9 @@ fn update_zoom_glide(app: &mut SheetApp, ui: &egui::Ui, place: (u64, usize), zoo let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); let z = if glides { zoom_glide_step(shown, to, share) } else { to }; + let from = Trace::key(app); let _ = app.session.run("view.zoom", json!({"percent": z})); + Trace::keep_through_zoom(app, from); zooming.glide = (z != to).then_some((to, z)); ui.ctx().request_repaint(); } @@ -725,32 +726,33 @@ pub(crate) fn paint_overlays(input: OverlayInput<'_>) { if let Some(o) = &app.grid.macro_overlay { crate::macro_editor::paint_overlay(p, geo, sh, o); } - // Trace precedents / dependents arrows. + // Trace Precedents, Trace Dependents and Trace Error arrows. if let Some(trace) = &app.grid.trace && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) - && trace.sheet == si - && let Some(items) = trace.items.as_array() { - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let active = geo.cell_rect(sh, trace.cell).center(); - for it in items { - if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { - continue; - } - let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); - let Some(t) = target else { continue }; - let tr = geo.range_rect(sh, t); - if !t.is_single() { - for pane in geo.range_panes(t) { - p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + let t = Tokens::get(p.ctx()); + let clip = p.clip_rect(); + for a in trace.arrows.iter().filter(|a| a.from.0 == si && a.to.0 == si) { + let colour = if a.error { t.sheet_trace_error } else { t.sheet_trace_arrow }; + let (fr, tr) = (geo.range_rect(sh, a.from.1), geo.range_rect(sh, a.to.1)); + for (range, rect) in [(a.from.1, fr), (a.to.1, tr)] { + if !range.is_single() { + for pane in geo.range_panes(range) { + p.with_clip_rect(pane.intersect(clip)).rect_stroke(rect, 0.0, Stroke::new(1.5, colour), StrokeKind::Inside); + } } } - let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; - p.line_segment([from, to], Stroke::new(1.5, blue)); - p.circle_filled(from, 3.0, blue); + let (from, to) = (fr.center(), tr.center()); + // An arrow wholly out of view costs nothing (a long Trace Error chain has thousands), + // and one from a cell to itself has no direction. + if !Rect::from_two_pos(from, to).expand(9.0).intersects(clip) || from.distance(to) < 1.0 { + continue; + } + p.line_segment([from, to], Stroke::new(1.5, colour)); + p.circle_filled(from, 3.0, colour); let d = (to - from).normalized(); let n = vec2(-d.y, d.x); - p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); + p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], colour, Stroke::NONE)); } } let _ = (wb, si); @@ -840,24 +842,25 @@ mod trace_arrow_tests { app.grid.trace = Trace::new(app, r); } - /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. - fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { + /// The lines `paint_overlays` draws on the active sheet scrolled by `scroll`, as (tail, head) + /// points in the colour given. + fn lines_in(app: &mut SheetApp, colour: Color32, scroll: egui::Vec2) -> (Geo, Vec<(Pos2, Pos2)>) { let d = app.session.active().unwrap(); let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); let sh = wb.sheet(si).unwrap(); - let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); + let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)); + let geo = Geo::new(sh, screen, scroll); let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { let p = ui.painter().clone(); paint_overlays(OverlayInput { app, ui, painter: &p, geo: &geo, workbook: &wb, sheet_index: si, sheet: sh, selection: &sel }); }); // No renderer consumes the font atlas upload here. out.textures_delta.clear(); - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); let mut lines = Vec::new(); for s in out.shapes { if let egui::Shape::LineSegment { points, stroke } = s.shape - && stroke.color == blue + && stroke.color == colour { lines.push((points[0], points[1])); } @@ -865,6 +868,32 @@ mod trace_arrow_tests { (geo, lines) } + /// The precedent and dependent arrows `paint_overlays` draws on the active sheet. + fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { + lines_in(app, Tokens::light().sheet_trace_arrow, vec2(0.0, 0.0)) + } + + /// The arrows Trace Error draws between cells showing the error. + fn error_arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { + lines_in(app, Tokens::light().sheet_trace_error, vec2(0.0, 0.0)) + } + + /// Cell centres for an arrow from `from` to `to`. + fn link(app: &SheetApp, geo: &Geo, from: &str, to: &str) -> (Pos2, Pos2) { + (center(app, geo, from), center(app, geo, to)) + } + + /// The H column of the sheet measured in Excel, H4 selected: Trace Error on H4 leads to H2, + /// whose own reference H1 has no error. + fn h_column() -> SheetApp { + let mut app = app(); + for (c, v) in [("E1", "10"), ("H1", "0"), ("H2", "=10/H1"), ("H3", "=H2+E1"), ("H4", "=H3*1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "H4"); + app + } + fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { let d = app.session.active().unwrap(); let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); @@ -945,4 +974,121 @@ mod trace_arrow_tests { assert_eq!(revisions[0], revisions[1]); assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); } + + #[test] + fn precedent_arrows_stay_through_a_zoom() { + let mut app = app(); + app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + app.run("view.zoom", json!({"percent": 75})).unwrap(); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines, [(center(&app, &geo, "B2"), center(&app, &geo, "F7"))], "#3097"); + // Zooming another sheet leaves them too, and they are back on returning to their sheet. + app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); + app.run("view.zoom", json!({"percent": 200})).unwrap(); + assert!(arrows(&mut app).1.is_empty()); + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines, [(center(&app, &geo, "B2"), center(&app, &geo, "F7"))]); + } + + #[test] + fn trace_error_draws_the_links_to_the_source_in_the_error_colour() { + let mut app = h_column(); + trace(&mut app, "formulas.traceError"); + select(&mut app, "A1"); + let (geo, red) = error_arrows(&mut app); + assert_eq!(red, [link(&app, &geo, "H2", "H3"), link(&app, &geo, "H3", "H4")], "H2 → H3 → H4, from the source outwards"); + let (geo, blue) = arrows(&mut app); + assert_eq!(blue, [link(&app, &geo, "H1", "H2")], "the source's own reference, like a precedent arrow; none from E1"); + assert_ne!(Tokens::light().sheet_trace_error, Tokens::light().sheet_trace_arrow); + } + + #[test] + fn trace_error_boxes_a_range_feeding_the_source() { + let mut app = app(); + for (c, v) in [("A2", "5"), ("J1", "=XLOOKUP(99,A2:A3,A2:A3)"), ("J2", "=J1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "J2"); + trace(&mut app, "formulas.traceError"); + let (geo, red) = error_arrows(&mut app); + assert_eq!(red, [link(&app, &geo, "J1", "J2")]); + let d = app.session.active().unwrap(); + let range = geo.range_rect(d.wb.sheet(0).unwrap(), RangeRef::parse("A2:A3").unwrap()); + let (_, blue) = arrows(&mut app); + assert_eq!(blue, [(range.center(), center(&app, &geo, "J1"))], "one arrow from the middle of A2:A3, not two"); + } + + #[test] + fn remove_arrows_and_edits_clear_trace_error_arrows() { + let mut app = h_column(); + trace(&mut app, "formulas.traceError"); + assert_eq!(error_arrows(&mut app).1.len(), 2); + // What the ribbon's Remove Arrows button does. + app.grid.trace = None; + assert!(error_arrows(&mut app).1.is_empty() && arrows(&mut app).1.is_empty()); + trace(&mut app, "formulas.traceError"); + app.run("cell.set", json!({"cell": "H1", "input": "2"})).unwrap(); + assert!(error_arrows(&mut app).1.is_empty(), "a change to the workbook removes them"); + } + + #[test] + fn trace_error_arrows_follow_the_zoom() { + let mut app = h_column(); + trace(&mut app, "formulas.traceError"); + let (_, before) = error_arrows(&mut app); + for id in ["view.zoom", "view.zoom100", "view.zoomToSelection"] { + app.run(id, json!({"percent": 150, "viewWidth": 1200.0, "viewHeight": 800.0})).unwrap(); + } + app.run("view.zoom", json!({"percent": 150})).unwrap(); + let (geo, red) = error_arrows(&mut app); + assert_eq!(red, [link(&app, &geo, "H2", "H3"), link(&app, &geo, "H3", "H4")], "still there after zooming: {red:?}"); + let length = |(a, b): (Pos2, Pos2)| a.distance(b); + assert!((length(red[0]) / length(before[0]) - 1.5).abs() < 0.05, "1.5 times as long at 150%: {red:?} {before:?}"); + assert_eq!(arrows(&mut app).1.len(), 1); + // Undoing the zoom is an edit like any other. + app.run("edit.undo", json!({})).unwrap(); + assert!(error_arrows(&mut app).1.is_empty()); + } + + #[test] + fn trace_error_arrows_stay_on_the_sheets_they_link() { + let mut app = app(); + app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); + for (c, v) in [("A1", "=1/0"), ("A2", "=A1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + for (c, v) in [("B1", "=Two!A2"), ("C1", "=B1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "C1"); + trace(&mut app, "formulas.traceError"); + let (geo, red) = error_arrows(&mut app); + assert_eq!(red, [link(&app, &geo, "B1", "C1")], "Two!A2 → B1 crosses sheets and isn't drawn"); + app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); + let (geo, red) = error_arrows(&mut app); + assert_eq!(red, [link(&app, &geo, "A1", "A2")], "the part of the chain on Two shows there"); + } + + #[test] + fn a_long_chain_paints_only_the_arrows_in_view() { + let mut app = app(); + let formulas: Vec<[String; 1]> = (1..=3000).map(|r| [if r == 1 { "=1/0".to_string() } else { format!("=A{}", r - 1) }]).collect(); + app.run("range.setValues", json!({"range": "A1", "values": formulas})).unwrap(); + select(&mut app, "A3000"); + trace(&mut app, "formulas.traceError"); + assert_eq!(app.grid.trace.as_ref().map(|t| t.arrows.len()), Some(2999)); + let colour = Tokens::light().sheet_trace_error; + let (geo, top) = lines_in(&mut app, colour, vec2(0.0, 0.0)); + assert!((30..=60).contains(&top.len()), "the rows on screen: {}", top.len()); + assert_eq!(top.first().copied(), Some(link(&app, &geo, "A1", "A2"))); + let (_, further) = lines_in(&mut app, colour, vec2(0.0, 20.0 * 1500.0)); + assert!((30..=60).contains(&further.len()), "{}", further.len()); + } } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 3ba6cb732..d64ad30bf 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -143,6 +143,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Odebrat šipky", "Show Formulas" => "Zobrazit vzorce", "Error Checking" => "Kontrola chyb", + "Trace Error" => "Sledovat chybu", "Circular References" => "Cyklické odkazy", "Evaluate Formula" => "Vyhodnotit vzorec", "Watch Window" => "Okno sledování", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 420214ffb..52d12709d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -137,6 +137,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Portrait" => "Hochformat", "Landscape" => "Querformat", "Custom Margins…" => "Benutzerdefinierte Seitenränder…", + "Page Setup" => "Seite einrichten", + "Left margin (in):" => "Linker Seitenrand (Zoll):", + "Right margin (in):" => "Rechter Seitenrand (Zoll):", + "Top margin (in):" => "Oberer Seitenrand (Zoll):", + "Bottom margin (in):" => "Unterer Seitenrand (Zoll):", + "Header (in):" => "Kopfzeile (Zoll):", + "Footer (in):" => "Fußzeile (Zoll):", + "Page order" => "Seitenreihenfolge", + "Down, then over" => "Seiten nach unten, dann nach rechts", + "Over, then down" => "Seiten nach rechts, dann nach unten", "Insert\nFunction" => "Funktion\neinfügen", "Name\nManager" => "Namens-\nmanager", "Define Name" => "Namen definieren", @@ -147,6 +157,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pfeile entfernen", "Show Formulas" => "Formeln anzeigen", "Error Checking" => "Fehlerüberprüfung", + "Trace Error" => "Spur zum Fehler", "Circular References" => "Zirkelbezüge", "Evaluate Formula" => "Formel auswerten", "Watch Window" => "Überwachungsfenster", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index cbc043ece..a5a232f6c 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Κατάργηση βελών", "Show Formulas" => "Εμφάνιση τύπων", "Error Checking" => "Έλεγχος σφαλμάτων", + "Trace Error" => "Εντοπισμός σφάλματος", "Circular References" => "Κυκλικές αναφορές", "Evaluate Formula" => "Αξιολόγηση τύπου", "Watch Window" => "Παράθυρο παρακολούθησης", @@ -675,6 +676,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Κάτω περιθώριο (ίντσες):", "Header (in):" => "Κεφαλίδα (ίντσες):", "Footer (in):" => "Υποσέλιδο (ίντσες):", + "Page order" => "Σειρά σελίδων", + "Down, then over" => "Προς τα κάτω και μετά δεξιά", + "Over, then down" => "Δεξιά και μετά προς τα κάτω", "Tab" => "Στηλοθέτης", "Semicolon" => "Ερωτηματικό (;)", "Comma" => "Κόμμα", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index f3df58b04..ad05f2082 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -146,6 +146,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", + "Trace Error" => "Rastrear error", "Circular References" => "Referencias circulares", "Evaluate Formula" => "Evaluar fórmula", "Watch Window" => "Ventana Inspección", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index e44696eb1..0a4d67271 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -157,6 +157,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", + "Trace Error" => "Repérer une erreur", "Circular References" => "Références circulaires", "Evaluate Formula" => "Évaluation de formule", "Watch Window" => "Fenêtre Espion", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 599b9c2a5..addc5070b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Rimuovi frecce", "Show Formulas" => "Mostra formule", "Error Checking" => "Controllo errori", + "Trace Error" => "Individua errore", "Circular References" => "Riferimenti circolari", "Evaluate Formula" => "Valuta formula", "Watch Window" => "Finestra controlli", @@ -556,6 +557,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Margine inferiore (poll.):", "Header (in):" => "Intestazione (poll.):", "Footer (in):" => "Piè di pagina (poll.):", + "Page order" => "Ordine pagine", + "Down, then over" => "Prima in basso, poi a destra", + "Over, then down" => "Prima a destra, poi in basso", "Tab" => "Tabulazione", "Semicolon" => "Punto e virgola", "Comma" => "Virgola", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 65015dfe0..5bd3e51d6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Remove Arrows", "矢印の解除"), ("Show Formulas", "数式の表示"), ("Error Checking", "エラーチェック"), + ("Trace Error", "エラーのトレース"), ("Circular References", "循環参照"), ("Evaluate Formula", "数式の検証"), ("Watch Window", "ウォッチ ウィンドウ"), @@ -578,6 +579,9 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Bottom margin (in):", "下余白 (インチ):"), ("Header (in):", "ヘッダー (インチ):"), ("Footer (in):", "フッター (インチ):"), + ("Page order", "ページの順序"), + ("Down, then over", "上から下へ、次に右へ"), + ("Over, then down", "左から右へ、次に下へ"), ("Tab", "タブ"), ("Semicolon", "セミコロン"), ("Comma", "コンマ"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 63159f7db..f8b687725 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "화살표 제거", "Show Formulas" => "수식 표시", "Error Checking" => "오류 검사", + "Trace Error" => "오류 추적", "Circular References" => "순환 참조", "Evaluate Formula" => "수식 계산", "Watch Window" => "감시 창", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index f45fe10a0..ad8fae6d1 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pijlen verwijderen", "Show Formulas" => "Formules weergeven", "Error Checking" => "Foutopsporing", + "Trace Error" => "Fout traceren", "Circular References" => "Kringverwijzingen", "Evaluate Formula" => "Formule evalueren", "Watch Window" => "Bewakingsvenster", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ae4639760..c11f43e1b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -146,6 +146,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", + "Trace Error" => "Śledź błąd", "Circular References" => "Odwołania cykliczne", "Evaluate Formula" => "Szacuj formułę", "Watch Window" => "Okno czujki", @@ -550,6 +551,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Dolny margines (cale):", "Header (in):" => "Nagłówek (cale):", "Footer (in):" => "Stopka (cale):", + "Page order" => "Kolejność stron", + "Down, then over" => "W dół, potem w poprzek", + "Over, then down" => "W poprzek, potem w dół", "Tab" => "Tabulator", "Semicolon" => "Średnik", "Comma" => "Przecinek", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 32b38a953..b11b9d9de 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -149,6 +149,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", + "Trace Error" => "Rastrear Erro", "Circular References" => "Referências Circulares", "Evaluate Formula" => "Avaliar Fórmula", "Watch Window" => "Janela de Inspeção", @@ -572,6 +573,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Margem inferior (pol.):", "Header (in):" => "Cabeçalho (pol.):", "Footer (in):" => "Rodapé (pol.):", + "Page order" => "Ordem das páginas", + "Down, then over" => "Para baixo, depois para a direita", + "Over, then down" => "Para a direita, depois para baixo", "Tab" => "Tabulação", "Semicolon" => "Ponto e vírgula", "Comma" => "Vírgula", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index f92555df2..9de0ccaa4 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -153,6 +153,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", + "Trace Error" => "Trasare eroare", "Circular References" => "Referințe circulare", "Evaluate Formula" => "Evaluare formulă", "Watch Window" => "Fereastră de urmărire", @@ -556,6 +557,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Margine jos (in):", "Header (in):" => "Antet (in):", "Footer (in):" => "Subsol (in):", + "Page order" => "Ordinea paginilor", + "Down, then over" => "În jos, apoi la dreapta", + "Over, then down" => "La dreapta, apoi în jos", "Tab" => "Tab", "Semicolon" => "Punct și virgulă", "Comma" => "Virgulă", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index f8d46d134..a3315d4bf 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Убрать стрелки", "Show Formulas" => "Показать формулы", "Error Checking" => "Проверка ошибок", + "Trace Error" => "Источник ошибки", "Circular References" => "Циклические ссылки", "Evaluate Formula" => "Вычислить формулу", "Watch Window" => "Окно контрольных значений", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 4ff6aa4ce..0f2e6cf77 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Ukloni strelice", "Show Formulas" => "Prikaži formule", "Error Checking" => "Provera grešaka", + "Trace Error" => "Prati grešku", "Circular References" => "Kružne reference", "Evaluate Formula" => "Proceni formulu", "Watch Window" => "Prozor za praćenje", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index f09e64e07..9234cc25f 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -145,6 +145,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", + "Trace Error" => "Hatayı İzle", "Circular References" => "Döngüsel Başvurular", "Evaluate Formula" => "Formülü Değerlendir", "Watch Window" => "Gözcü Penceresi", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 699a22288..716fadd2e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Прибрати стрілки", "Show Formulas" => "Показати формули", "Error Checking" => "Перевірка помилок", + "Trace Error" => "Джерело помилки", "Circular References" => "Циклічні посилання", "Evaluate Formula" => "Обчислити формулу", "Watch Window" => "Окно контрольних значень", @@ -554,6 +555,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Bottom margin (in):", "Header (in):" => "Header (in):", "Footer (in):" => "Footer (in):", + "Page order" => "Порядок сторінок", + "Down, then over" => "Вниз, потім праворуч", + "Over, then down" => "Праворуч, потім униз", "Tab" => "Tab", "Semicolon" => "Semicolon", "Comma" => "Comma", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 9063b7d76..6ab6a71c4 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -154,6 +154,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", + "Trace Error" => "Truy tìm 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", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index a95f7684d..c45e5c610 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "删除箭头", "Show Formulas" => "显示公式", "Error Checking" => "错误检查", + "Trace Error" => "追踪错误", "Circular References" => "循环引用", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index c74153c1b..1f64d55ad 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -57,6 +57,9 @@ pub struct Editor { pub draft: Macro, /// The name the macro is saved under (`None` until it is first saved). pub saved_as: Option, + /// The name the editor opened with: a new macro that still has it and nothing else counts + /// as untouched. + pub opened_as: String, pub dirty: bool, /// The selected row: a step path (`3`, `2.1`, `4.then.1`) or an If branch (`4.then`). pub selected: Option, @@ -79,6 +82,9 @@ pub struct Editor { pub close_requested: bool, /// The window is quitting: once the editor has closed, the window close goes on. pub quit: bool, + /// The workbook (or the editor's window) is closing: once the editor has closed, this close + /// runs again, in the window it was asked in. + pub then_close: Option, /// Asking whether to save before closing. pub ask_close: bool, /// Start debugging on the first frame (Macros › Step Into). @@ -94,6 +100,20 @@ pub struct Editor { pub refs: RefPick, } +/// A workbook or window close (`file.close` or `window.close`, with its params) waiting on the +/// editor's question about its draft. Once the editor has closed, it runs again in the window it +/// was asked in, whichever window became active meanwhile. +#[derive(Clone, Debug, PartialEq)] +pub struct PendingClose { + /// The editor's window, where the close was asked. + pub window: u64, + /// Whether the workbook closes (its last window, or `file.close {document: true}`), rather + /// than the editor's window alone while the workbook stays open in another. + pub whole: bool, + pub id: String, + pub params: Json, +} + /// Which reference box grid selections write into, and the selection to put back on closing. #[derive(Clone, Debug, Default)] pub struct RefPick { @@ -523,6 +543,7 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { let doc = doc.uid; app.macros.editor = Some(Editor { doc, + opened_as: draft.name.clone(), draft, dirty: saved_as.is_none(), saved_as, @@ -538,6 +559,7 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { closing: false, close_requested: false, quit: false, + then_close: None, ask_close: false, start_debug, start_check: params["check"].as_bool() == Some(true), @@ -612,6 +634,77 @@ fn ensure_saved(app: &mut SheetApp, ed: &mut Editor) -> bool { if ed.dirty || ed.saved_as.is_none() { save(app, ed) } else { true } } +impl Editor { + /// The draft holds changes not yet saved into the workbook. A new macro counts once it + /// differs from the empty one the editor started with, its name included. + pub fn has_unsaved_changes(&self) -> bool { + let untouched = Macro { name: self.opened_as.clone(), inputs: vec![NamedInput::default()], ..Default::default() }; + self.dirty && (self.saved_as.is_some() || self.draft != untouched) + } +} + +/// Closing the editor's workbook (File › Close, Cmd+W, the Backstage Close, a window's close +/// button) while the draft has unsaved changes: the editor asks about them first, as its own +/// close button does, and the close (`id` with `params`) goes on once the editor has closed, in +/// the window it was asked in; Cancel keeps both. Closing the window that shows the editor asks +/// too, even when the workbook stays open in another window. A close of another of the +/// workbook's windows, or a forced one, is none of the editor's business (`None`) and goes ahead +/// as before. +/// +/// While the editor is asking, one question stands for one close: a further request for the +/// same close (Cmd+W pressed again, the close button after Cmd+W) waits for the answer without a +/// second question, and the pending close then runs once. A request for a different close (the +/// workbook while only the editor's window was closing, or the reverse) is refused with the +/// reason until the question is answered, so that the answer never closes more or less than +/// what was asked. +pub fn ask_before_closing(app: &mut SheetApp, id: &str, params: &Json) -> Option> { + let flag = |k: &str| params.get(k).and_then(Json::as_bool) == Some(true); + if flag("force") { + return None; + } + let doc = app.macros.editor.as_ref().filter(|ed| ed.has_unsaved_changes()).map(|ed| ed.doc)?; + let window = match id { + "file.close" => app.session.active_window(), + "window.close" => gridcraft_engine::Target::from_json(params).ok().and_then(|t| app.session.target_window(t).ok()), + _ => None, + }; + let owner = window.and_then(|w| app.session.window(w)).map(|w| w.doc_uid)?; + // The editor asks in the window it is shown in. + let home = app.window_with_dialog("macroEditor")?; + let only = app.session.windows().iter().filter(|w| w.doc_uid == owner).count() <= 1; + // The workbook closes, or the window that shows the editor does (taking the editor with it). + let whole = only || (id == "file.close" && flag("document")); + if owner != doc || !(whole || window == Some(home)) { + return None; + } + if app.ui_window() != Some(home) { + app.enter_window(home); + } + let ed = app.macros.editor.as_mut()?; + let waiting = json!({"closed": false, "dirty": true, "macroDraft": true}); + // Quitting: the quit asks about every unsaved workbook itself once the editor has closed, + // and Cancel keeps them all. + if ed.quit { + return Some(Ok(waiting)); + } + match &ed.then_close { + Some(pending) if pending.whole == whole => {} + Some(pending) => { + let what = |whole: bool| if whole { "the workbook" } else { "its window" }; + return Some(Err(format!( + "The macro step editor is asking whether to save its draft before {} closes. Answer that first, then close {}.", + what(pending.whole), + what(whole) + ))); + } + None => { + ed.then_close = Some(PendingClose { window: home, whole, id: id.to_string(), params: params.clone() }); + ed.close_requested = true; + } + } + Some(Ok(waiting)) +} + /// The inputs a run from the editor uses: the first input's example. fn run_inputs(ed: &Editor) -> Json { match ed.draft.inputs.first() { @@ -863,7 +956,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu if app.session.macro_debug.is_some() { ed.ask_stop = true; ed.closing = true; - } else if ed.dirty { + } else if ed.has_unsaved_changes() { ed.ask_close = true; } else { *open = false; @@ -882,14 +975,32 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu restore_home(app, &ed.refs); } if ed.quit { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + // A quit takes over a workbook close waiting on the editor: it asks about every + // unsaved workbook itself, and the close replayed after it would act on whichever + // workbook the quit is asking about. + if app.native_windows { + // Each workbook window is a native window whose close button closes only it: + // the quit goes on directly. + app.continue_quit(ctx); + } else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } else if let Some(close) = ed.then_close.take() { + // The close runs in the window it was asked in, not in whichever window the person + // activated meanwhile: `file.close` acts on the active window and workbook. + if app.session.window(close.window).is_some() { + app.enter_window(close.window); + app.run_or_alert(&close.id, close.params); + } else { + app.message = Some(("Close".into(), "The window that was closing has closed already; nothing else was closed.".into())); + } } } } /// Asks the open step editor to close, as its close button does: it asks about a debug run or -/// unsaved steps first. With `quit` (the window is closing), the window close goes on once the -/// editor has closed; a Cancel in its questions keeps both open. +/// unsaved steps first. With `quit` (the window is closing, or the app is quitting), the close or +/// quit goes on once the editor has closed; a Cancel in its questions keeps both open. pub fn request_close(app: &mut SheetApp, quit: bool) { if let Some(ed) = app.macros.editor.as_mut() { ed.close_requested = true; @@ -904,7 +1015,7 @@ pub fn close_now(app: &mut SheetApp) -> Result<(), String> { if app.session.macro_debug.is_some() { return Err("A macro is being debugged: stop it first (macro.debug {\"action\": \"stop\"}).".into()); } - if app.macros.editor.as_ref().is_some_and(|ed| ed.dirty) { + if app.macros.editor.as_ref().is_some_and(Editor::has_unsaved_changes) { return Err("The macro step editor has unsaved steps: save them or close the editor in its window.".into()); } if let Some(ed) = app.macros.editor.take() @@ -1018,7 +1129,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_stop = false; stop(app, ed, keep); if ed.closing { - if ed.dirty { ed.ask_close = true } else { *open = false } + if ed.has_unsaved_changes() { ed.ask_close = true } else { *open = false } } ed.closing = false; } @@ -1026,6 +1137,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_stop = false; ed.closing = false; ed.quit = false; + ed.then_close = None; } None => {} } @@ -1054,6 +1166,11 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_close = false; if save(app, ed) { *open = false; + } else { + // The draft stays open with the reason shown; the workbook is not closed and + // the app does not quit, then or when the editor closes later. + ed.then_close = None; + ed.quit = false; } } Some(Some(false)) => { @@ -1063,6 +1180,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut Some(None) => { ed.ask_close = false; ed.quit = false; + ed.then_close = None; } None => {} } diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 4277721b8..9d1d74f33 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -181,10 +181,16 @@ pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { app.toast = Some((text, crate::now_ms())); } -/// Drops the step editor once its workbook is closed. The editor's dialog stays with its -/// workbook's window, so it may not be drawn again to notice. +/// Drops the step editor once its workbook is closed, or once no window shows it (the window it +/// was shown in closed while the workbook stays open in another). The editor's dialog stays with +/// its window, so it may not be drawn again to notice. pub fn forget_closed_editor(app: &mut SheetApp) { - if app.macros.editor.as_ref().is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc)) { + if app + .macros + .editor + .as_ref() + .is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc) || app.window_with_dialog("macroEditor").is_none()) + { app.macros.editor = None; app.grid.macro_overlay = None; } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index ec6146018..af05563ea 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -241,6 +241,28 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } }); }); + ui.collapsing("Data Table", |ui| { + let supported = chart.supports_data_table(); + let mut on = chart.data_table.is_some(); + if ui.add_enabled(supported, egui::Checkbox::new(&mut on, "Show Data Table")).changed() { + app.run_or_alert("chart.set", json!({"chart": id, "dataTable": on})); + } + if !supported { + ui.label("Available for column, bar, line, area and compatible combo charts."); + } + if let Some(table) = &chart.data_table { + for (label, key, mut value) in [ + ("Show Legend Keys", "dataTableKeys", table.show_keys), + ("Horizontal Borders", "dataTableHorizontalBorder", table.horizontal_border), + ("Vertical Borders", "dataTableVerticalBorder", table.vertical_border), + ("Outline", "dataTableOutline", table.outline), + ] { + if ui.checkbox(&mut value, label).changed() { + app.run_or_alert("chart.set", json!({"chart": id, key: value})); + } + } + } + }); ui.collapsing("Series", |ui| { let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &chart)); for (i, s) in chart.series.iter().enumerate() { diff --git a/crates/ui-egui/src/ribbon/contextual.rs b/crates/ui-egui/src/ribbon/contextual.rs index 469e76cf0..49f8418f9 100644 --- a/crates/ui-egui/src/ribbon/contextual.rs +++ b/crates/ui-egui/src/ribbon/contextual.rs @@ -126,6 +126,9 @@ pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) { ("-", "", json!(null)), ("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})), ("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})), + ("Data Table: With Legend Keys", "chart.addElement", json!({"chart": id, "element": "dataTable", "dataTableKeys": true})), + ("Data Table: No Legend Keys", "chart.addElement", json!({"chart": id, "element": "dataTable", "dataTableKeys": false})), + ("Data Table: None", "chart.addElement", json!({"chart": id, "element": "dataTable", "on": false})), ("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})), ("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})), ], diff --git a/crates/ui-egui/src/ribbon/formulas.rs b/crates/ui-egui/src/ribbon/formulas.rs index 464baf4fd..c61b8aa75 100644 --- a/crates/ui-egui/src/ribbon/formulas.rs +++ b/crates/ui-egui/src/ribbon/formulas.rs @@ -180,6 +180,14 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) { if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() { app.open_dialog("errorChecking", json!({})); } + // A cell without an error, or one its error starts in without references, has no + // arrows: the ones shown stay. + if ui.button(lang.tr("Trace Error")).clicked() + && let Ok(r) = app.run("formulas.traceError", json!({})) + && r["arrows"].as_array().is_some_and(|a| !a.is_empty()) + { + app.grid.trace = crate::grid::Trace::new(app, r); + } let circular = circular_references(app); ui.add_enabled_ui(!circular.is_empty(), |ui| { ui.menu_button(lang.tr("Circular References"), |ui| { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index a730e77d8..6702f7cb8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -387,7 +387,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { let slider = ui.add(egui::Slider::new(&mut z, 10.0..=400.0).show_value(false).logarithmic(true)); ui.ctx().accesskit_node_builder(slider.id, |node| node.set_label(app.ui.language.tr("Zoom"))); if slider.changed() { + let from = crate::grid::Trace::key(app); let _ = app.session.run("view.zoom", json!({"percent": z.round()})); + crate::grid::Trace::keep_through_zoom(app, from); } if crate::widgets::icon_button(ui, Icon::Minus, t.text_dim, "Zoom Out", vec2(18.0, 18.0)).clicked() { let z = ((zoom as f32 / 10.0).ceil() * 10.0 - 10.0).max(10.0); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f37eb0c35..fe31dfb6a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -114,6 +114,10 @@ pub struct Tokens { pub sheet_macro_footprint: Color32, pub sheet_macro_conflict: Color32, pub sheet_macro_target: Color32, + /// Trace arrows on the sheet: precedents and dependents, and the links Trace Error follows + /// between cells showing an error. Sheet colours. + pub sheet_trace_arrow: Color32, + pub sheet_trace_error: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -146,6 +150,9 @@ const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8); const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D); const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C); const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0); +/// Trace arrows on the sheet, the same in both themes. +const SHEET_TRACE_ARROW: Color32 = rgb(0x1F5FC9); +const SHEET_TRACE_ERROR: Color32 = rgb(0xD13438); /// Code colours for one background; plain text uses the surface's own text colour. #[derive(Clone, Copy, Debug, PartialEq)] @@ -244,6 +251,8 @@ impl Tokens { sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, sheet_macro_conflict: SHEET_MACRO_CONFLICT, sheet_macro_target: SHEET_MACRO_TARGET, + sheet_trace_arrow: SHEET_TRACE_ARROW, + sheet_trace_error: SHEET_TRACE_ERROR, } } @@ -395,6 +404,8 @@ mod contrast_tests { ] { add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH); } + add("trace arrow on sheet", t.sheet_trace_arrow, t.grid_bg, GLYPH); + add("trace error arrow on sheet", t.sheet_trace_error, t.grid_bg, GLYPH); // The macro step editor: breakpoint dots in the gutter, error markers, the current step. add("breakpoint on window", t.breakpoint, t.window, GLYPH); add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH); @@ -439,6 +450,7 @@ mod contrast_tests { (l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target), (d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target) ); + assert_eq!((l.sheet_trace_arrow, l.sheet_trace_error), (d.sheet_trace_arrow, d.sheet_trace_error)); for c in Cat::ALL { assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own"); } diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index dbabe9b28..1c20feb5e 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -145,6 +145,19 @@ impl SheetApp { } } + /// The window that shows the dialog `name`: the current one or a parked one. A window that has + /// closed shows nothing, even while its parked state is still kept. + pub(crate) fn window_with_dialog(&self, name: &str) -> Option { + if self.dialog.as_ref().is_some_and(|d| d.name == name) { + return self.ui_window; + } + self.parked + .iter() + .filter(|(id, w)| w.dialog.as_ref().is_some_and(|d| d.name == name) && self.session.window(**id).is_some()) + .map(|(id, _)| *id) + .min() + } + /// Whether a window other than the current one has a cell or text-box draft open. pub fn parked_window_with_draft(&self) -> Option { self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min() diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index 1d72739e3..595f50707 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -18,6 +18,8 @@ thread_local! { static INFOS: RefCell> = RefCell::new(egui::ViewportIdMap::default()); /// The viewport commands the app sent this frame. static SENT: RefCell> = const { RefCell::new(Vec::new()) }; + /// Input events for the root window's next frame (a key pressed in it). + static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; } fn record(mut out: egui::FullOutput) { @@ -65,6 +67,7 @@ impl Desk { let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() }; input.viewports = INFOS.with(|i| i.borrow().clone()); input.viewports.entry(ViewportId::ROOT).or_default(); + input.events = EVENTS.with(|e| std::mem::take(&mut *e.borrow_mut())); let Desk { ctx, app, windows } = self; let mut act = Some(act); let out = ctx.clone().run_ui(input, |ui| { @@ -115,6 +118,28 @@ impl Desk { let uid = self.app.session.documents()[doc].uid; self.app.session.doc_window(uid).unwrap_or_default() } + + /// The user presses `key` in the root window. + fn key(&mut self, key: egui::Key) -> Vec<(ViewportId, ViewportCommand)> { + EVENTS.with(|e| { + e.borrow_mut().push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }) + }); + let mut sent = self.frame_with(|_, _| {}); + sent.extend(self.frames(2)); + sent + } + + /// Opens the step editor on a new macro in the active window and edits it, so the draft has + /// unsaved changes. + fn edited_new_macro(&mut self) { + self.app.open_dialog("macroEditor", json!({})); + self.frames(3); + if let Some(ed) = self.app.macros.editor.as_mut() { + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "7"}))); + ed.dirty = true; + } + self.frames(2); + } } fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &ViewportCommand) -> bool { @@ -437,3 +462,116 @@ fn arrange_all_lays_out_the_native_windows_active_first() { let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h); assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]); } + +#[test] +fn quit_with_the_macro_editor_open_goes_on_once_the_editor_has_closed() { + let mut desk = Desk::new(false); + desk.app.run("macro.save", json!({"macro": {"name": "Kept", "steps": []}})).unwrap(); + let d = desk.app.session.active_mut().unwrap(); + d.saved = d.wb.clone(); + desk.app.open_dialog("macroEditor", json!({"macro": "Kept"})); + desk.frames(3); + assert!(desk.app.macros.editor.is_some()); + // The editor has nothing unsaved, so it closes; then the quit goes on as the app's quit, not + // as the editor window's close button (which would close only that workbook's window). + let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + sent.extend(desk.frames(2)); + assert!(desk.app.macros.editor.is_none()); + assert!(desk.app.allow_close, "the quit goes on: {sent:?}"); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); + assert_eq!(desk.app.session.documents().len(), 1, "the workbook was not closed on its own"); +} + +#[test] +fn quit_takes_over_a_workbook_close_waiting_on_the_macro_editor() { + let mut desk = Desk::new(false); + let w1 = desk.window_of(0); + desk.app.open_dialog("macroEditor", json!({})); + desk.frames(3); + if let Some(ed) = desk.app.macros.editor.as_mut() { + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "7"}))); + ed.dirty = true; + } + desk.frames(2); + // Book2 has unsaved changes; Book1, with the editor, is saved. + desk.app.run("file.new", json!({})).unwrap(); + desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + desk.frames(3); + let w2 = desk.window_of(1); + desk.app.enter_window(w1); + desk.frames(2); + // File › Close on Book1 waits on the editor's question; then Quit takes over. + let r = desk.app.run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + desk.frames(3); + desk.frame_with(|app, ctx| app.request_quit(ctx)); + // Don't Save (the question's answer closes the editor without saving). + if let Some(ed) = desk.app.macros.editor.as_mut() { + ed.ask_close = false; + ed.dirty = false; + ed.close_requested = true; + } + desk.frames(3); + assert!(desk.app.macros.editor.is_none()); + // The quit asks about Book2 in its window, as the quit's question, not as a File › Close. + assert_eq!(desk.app.session.active_window(), Some(w2)); + let d = desk.app.dialog.as_ref().expect("Book2 is asked about"); + assert_eq!(d.name(), "saveChanges"); + assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values); + assert_eq!(desk.app.session.documents().len(), 2, "nothing closed before the answer"); +} + +#[test] +fn quit_with_a_draft_in_one_window_and_unsaved_cells_in_another_asks_in_order_and_cancel_stops_it() { + let mut desk = Desk::new(false); + // Book1 (the root window), saved, with an unsaved draft; Book2 with unsaved cells. + let w1 = desk.window_of(0); + desk.edited_new_macro(); + desk.app.run("file.new", json!({})).unwrap(); + desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + desk.frames(3); + let w2 = desk.window_of(1); + assert_eq!(desk.app.session.active_window(), Some(w2)); + // Quit from Book2's window: the editor's question comes first, in Book1's window. + let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + sent.extend(desk.frames(2)); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}"); + assert_eq!(desk.app.session.active_window(), Some(w1), "the editor's window comes forward"); + assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| ed.ask_close && ed.quit), "the editor asks"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no workbook is asked about yet"); + // Don't Save (the question's answer closes the editor without saving). + if let Some(ed) = desk.app.macros.editor.as_mut() { + ed.ask_close = false; + ed.dirty = false; + ed.close_requested = true; + } + desk.frames(3); + assert!(desk.app.macros.editor.is_none()); + // Book2 is asked about next, in its window, as the quit's question. + assert_eq!(desk.app.session.active_window(), Some(w2), "the unsaved workbook's window comes forward"); + let d = desk.app.dialog.as_ref().expect("Book2 is asked about"); + assert_eq!(d.name(), "saveChanges"); + assert_eq!(d.values.get("title").and_then(serde_json::Value::as_str), Some("Book2")); + assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values); + assert_eq!(desk.app.session.documents().len(), 2, "nothing closed before the answer"); + // Cancel: the quit stops with both windows and workbooks open; the discarded draft stays + // discarded. + let sent = desk.answer(SaveAnswer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}"); + assert_eq!(desk.app.session.documents().len(), 2); + assert_eq!(desk.windows.shown_windows(), [w1, w2]); + assert!(desk.app.dialog.is_none() && desk.app.macros.editor.is_none()); + assert!(desk.app.session.documents()[1].is_dirty(), "Book2 keeps its changes"); + // A quit cancelled at the editor's question (Escape) stops before any workbook is asked about. + desk.app.enter_window(w1); + desk.edited_new_macro(); + let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + sent.extend(desk.frames(2)); + assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| ed.ask_close && ed.quit), "the editor asks"); + sent.extend(desk.key(egui::Key::Escape)); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}"); + assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| !ed.ask_close && !ed.quit && ed.draft.steps.len() == 1), "the draft is kept"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no Save Changes question followed"); + assert_eq!(desk.app.session.documents().len(), 2); + assert_eq!(desk.windows.shown_windows(), [w1, w2]); +} diff --git a/crates/ui-egui/tests/it/chart_data_table.rs b/crates/ui-egui/tests/it/chart_data_table.rs new file mode 100644 index 000000000..aedb1ebfc --- /dev/null +++ b/crates/ui-egui/tests/it/chart_data_table.rs @@ -0,0 +1,32 @@ +use egui::accesskit::Role; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use serde_json::json; + +#[test] +fn format_chart_pane_controls_the_engine_data_table() { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); + let id = session.execute("insert.chart", json!({"range": "A1:C3", "type": "column", "at": "E3"})).unwrap()["chart"].as_u64().unwrap() as u32; + let mut h = crate::support::harness(session, 4); + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::DisclosureTriangle, "Data Table").click(); + h.run_steps(2); + let table = |h: &crate::common::Harness| h.state().session.active().unwrap().wb.active().unwrap().charts[0].data_table.clone(); + assert_eq!(table(&h), None); + h.get_by_label("Show Data Table").click(); + h.run_steps(2); + assert!(table(&h).unwrap().show_keys); + h.get_by_label("Show Legend Keys").click(); + h.run_steps(2); + assert!(!table(&h).unwrap().show_keys); + h.get_by_label("Horizontal Borders").click(); + h.run_steps(2); + assert!(!table(&h).unwrap().horizontal_border); + h.get_by_label("Show Data Table").click(); + h.run_steps(2); + assert_eq!(table(&h), None); +} diff --git a/crates/ui-egui/tests/it/error_checking_menu.rs b/crates/ui-egui/tests/it/error_checking_menu.rs index bd8588db5..e06228bae 100644 --- a/crates/ui-egui/tests/it/error_checking_menu.rs +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -1,11 +1,14 @@ //! 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. +//! dialog, and its arrow opens a menu whose Trace Error item draws arrows back to the cell an error +//! starts in, and 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 gridcraft_ui_egui::i18n::Language; +use gridcraft_ui_egui::theme::Tokens; use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; @@ -160,3 +163,123 @@ fn circular_references_scrolls_a_list_taller_than_the_window() { click(&mut h, last.center()); assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100"); } + +/// The Formulas tab in `language` over part of the sheet measured in Excel, C1 selected: C1 shows +/// A1's `#DIV/0!` through B1, and H4 shows H2's through H3. +fn trace_harness(language: Language) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [ + ("A1", "=1/0"), + ("A2", "5"), + ("B1", "=A1+A2"), + ("C1", "=B1*2"), + ("E1", "10"), + ("H1", "0"), + ("H2", "=10/H1"), + ("H3", "=H2+E1"), + ("H4", "=H3*1"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s.execute("selection.set", json!({"range": "C1"})).unwrap(); + let mut app = crate::common::app(s); + app.ui.language = language; + app.ui.ribbon_tab = "Formulas".into(); + 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); + }, + app, + ); + h.run_steps(4); + h +} + +/// Opens the Error Checking menu, whose button reads `text`. +fn open_menu_labelled(h: &mut Harness, text: &str) { + let (_, right, y) = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0)), + _ => None, + }) + .unwrap_or_else(|| panic!("the Formulas tab shows {text}")); + click(h, pos2(right + 13.0, y)); +} + +/// The trace arrows the app keeps, as (from, to, drawn in the error colour). +fn traced(h: &Harness) -> Vec<(String, String, bool)> { + let trace = h.state().grid.trace.as_ref(); + trace.map(|t| t.arrows.iter().map(|a| (a.from.1.a1(), a.to.1.a1(), a.error)).collect()).unwrap_or_default() +} + +/// Line segments painted in `colour`. +fn painted(h: &Harness, colour: egui::Color32) -> usize { + h.output().shapes.iter().filter(|s| matches!(&s.shape, Shape::LineSegment { stroke, .. } if stroke.color == colour)).count() +} + +fn link(from: &str, to: &str, error: bool) -> (String, String, bool) { + (from.to_string(), to.to_string(), error) +} + +#[test] +fn trace_error_draws_the_arrows_to_the_source_and_remove_arrows_clears_them() { + let mut h = trace_harness(Language::En); + open_menu_labelled(&mut h, "Error Checking"); + // As in Excel: Error Checking…, Trace Error, Circular References. + let y = |h: &Harness, l: &str| h.get_by_label(l).rect().center().y; + assert!(y(&h, "Error Checking…") < y(&h, "Trace Error") && y(&h, "Trace Error") < y(&h, CIRCULAR)); + assert!(!h.get_by_label("Trace Error").accesskit_node().is_disabled()); + let undo = h.state().session.active().unwrap().undo.len(); + h.get_by_label("Trace Error").click(); + h.run_steps(4); + assert_eq!(traced(&h), [link("A1", "B1", true), link("B1", "C1", true)]); + let red = Tokens::light().sheet_trace_error; + assert_eq!(painted(&h, red), 2, "two red arrows on the sheet"); + assert!(h.state().dialog.is_none() && h.state().message.is_none(), "no dialog, no message"); + assert_eq!(h.state().session.active().unwrap().undo.len(), undo, "no undo step"); + // H4: the source H2's own reference H1 gets a blue arrow; the selection moving leaves them. + h.state_mut().run("selection.set", json!({"range": "H4"})).unwrap(); + open_menu_labelled(&mut h, "Error Checking"); + h.get_by_label("Trace Error").click(); + h.run_steps(4); + h.state_mut().run("selection.set", json!({"range": "A20"})).unwrap(); + h.run_steps(4); + assert_eq!(traced(&h), [link("H1", "H2", false), link("H2", "H3", true), link("H3", "H4", true)]); + assert_eq!((painted(&h, red), painted(&h, Tokens::light().sheet_trace_arrow)), (2, 1)); + h.get_by_label("Remove Arrows").click(); + h.run_steps(4); + assert!(h.state().grid.trace.is_none()); + assert_eq!(painted(&h, red), 0); +} + +#[test] +fn trace_error_on_a_cell_without_arrows_keeps_the_ones_shown() { + let mut h = trace_harness(Language::En); + open_menu_labelled(&mut h, "Error Checking"); + h.get_by_label("Trace Error").click(); + h.run_steps(4); + // E1 has no error, and A1's error starts there without references: nothing to draw. + for cell in ["E1", "A1"] { + h.state_mut().run("selection.set", json!({"range": cell})).unwrap(); + open_menu_labelled(&mut h, "Error Checking"); + h.get_by_label("Trace Error").click(); + h.run_steps(4); + assert_eq!(traced(&h), [link("A1", "B1", true), link("B1", "C1", true)], "{cell}"); + assert!(h.state().message.is_none(), "{cell}"); + } +} + +#[test] +fn the_german_menu_says_spur_zum_fehler() { + let mut h = trace_harness(Language::De); + open_menu_labelled(&mut h, Language::De.tr("Error Checking")); + assert_eq!(Language::De.tr("Trace Error"), "Spur zum Fehler"); + h.get_by_label("Spur zum Fehler").click(); + h.run_steps(4); + assert_eq!(traced(&h), [link("A1", "B1", true), link("B1", "C1", true)]); +} diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 4b6ff1a96..5ae1fb3bb 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -763,6 +763,117 @@ fn data_analysis_dialog_runs_a_tool_and_asks_before_overwriting() { assert!(h.state().message.is_none()); } +#[test] +fn data_analysis_dialog_opens_every_tool_form() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [2, 4], [3, 6]]})).unwrap(); + let mut h = harness(s); + for (id, label) in gridcraft_engine::cmd::analysis::TOOLS { + h.state_mut().open_dialog("analysisToolPak", json!({"tool": id})); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.title.clone()).as_deref(), Some(*label), "{id}"); + h.state_mut().dialog = None; + } + // The z-Test form runs with the known variances typed in. + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "zTest"})); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in [("range1", "A1:A3"), ("range2", "B1:B3"), ("variance1", "1"), ("variance2", "4"), ("outputTo", "range"), ("output", "D1")] { + d.values.insert(k.into(), json!(v)); + } + } + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().dialog.is_none(), "OK runs the tool and closes the dialog"); + assert_eq!(value(&h, "D1"), Value::from("z-Test: Two Sample for Means")); + // Random Number Generation draws from the chosen distribution with the typed seed. + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "randomNumberGeneration"})); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in [("count", "3"), ("distribution", "bernoulli"), ("seed", "1"), ("outputTo", "range"), ("output", "H1")] { + d.values.insert(k.into(), json!(v)); + } + } + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert!(matches!(value(&h, "H3"), Value::Number(x) if x == 0.0 || x == 1.0)); +} + +#[test] +fn two_factor_anova_without_replication_labels_caption_names_the_first_column() { + // Ticked, Labels makes the tool take the first cell of every data row as the row's name, so + // the caption must not say the labels are only in the first row. + let mut h = harness(blank()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "anovaTwoFactorWithout"})); + h.run_steps(2); + assert!(h.query_by_label("Labels in first row and first column").is_some()); + assert!(h.query_by_label("Labels in first row").is_none()); +} + +#[test] +fn random_number_generation_form_hides_the_count_for_a_patterned_sequence() { + // A patterned sequence ignores the count (its from, to, step and repeats set the length), so + // the form offers the count only for the random distributions. + let mut h = harness(blank()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "randomNumberGeneration"})); + h.run_steps(2); + assert!(h.query_by_label("Number of Random Numbers:").is_some()); + h.state_mut().dialog.as_mut().unwrap().values.insert("distribution".into(), json!("patterned")); + h.run_steps(2); + assert!(h.query_by_label("Number of Random Numbers:").is_none()); + assert!(h.query_by_label("Repeating each sequence (times):").is_some()); +} + +#[test] +fn random_seed_caption_says_blank_draws_a_new_sequence() { + // A blank seed doesn't mean "no seed": the engine picks a new one, so the numbers differ on + // each run. The caption says so for both tools that draw random numbers. + let mut h = harness(blank()); + for tool in ["randomNumberGeneration", "sampling"] { + h.state_mut().open_dialog("analysisToolPak", json!({"tool": tool})); + h.state_mut().dialog.as_mut().unwrap().values.insert("method".into(), json!("random")); + h.run_steps(2); + assert!(h.query_by_label("Random Seed (blank for a new sequence):").is_some(), "{tool}"); + assert!(h.query_by_label("Random Seed (blank for none):").is_none(), "{tool}"); + h.state_mut().dialog = None; + } +} + +#[test] +fn data_analysis_form_keeps_ok_on_screen_in_a_short_window() { + // The patterned Random Number Generation form is the tallest; in a 600-point-high window the + // tool's options scroll and the output options and OK stay reachable. + let mut h = crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1280.0, 600.0)), crate::common::app(blank()), 4); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "randomNumberGeneration"})); + h.state_mut().dialog.as_mut().unwrap().values.insert("distribution".into(), json!("patterned")); + h.run_steps(4); + // OK's label is padded; a button clipped off the window has no node at all. + let ok = h.query_by_label(" OK ").map(|n| n.rect()); + assert!(ok.is_some_and(|r| r.bottom() <= 600.0), "OK at {ok:?} in a 600-point window"); + let output = h.query_by_label("New Worksheet Ply:").map(|n| n.rect()); + assert!(output.is_some_and(|r| r.bottom() <= 600.0), "the output options at {output:?}"); +} + +#[test] +fn random_seed_shows_only_where_numbers_are_drawn() { + // Patterned generation and periodic sampling draw nothing, so their forms have no seed. + let seed = "Random Seed (blank for a new sequence):"; + let mut h = harness(blank()); + for (tool, key, value, shown) in [ + ("randomNumberGeneration", "distribution", "uniform", true), + ("randomNumberGeneration", "distribution", "patterned", false), + ("sampling", "method", "random", true), + ("sampling", "method", "periodic", false), + ] { + h.state_mut().open_dialog("analysisToolPak", json!({"tool": tool})); + h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(value)); + h.run_steps(2); + assert_eq!(h.query_by_label(seed).is_some(), shown, "{tool} {value}"); + h.state_mut().dialog = None; + } +} + #[test] fn solver_dialog_loads_the_saved_model_and_results_can_restore() { let mut s = blank(); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index f2130fcb6..6523dd039 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -5,16 +5,20 @@ use egui::{Event, Key, Modifiers, PointerButton}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; -use gridcraft_engine::model::StepKind; -use gridcraft_ui_egui::SheetApp; +use gridcraft_engine::model::{Step, StepKind}; +use gridcraft_ui_egui::{Services, SheetApp}; use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { + harness_with(Default::default()) +} + +fn harness_with(services: Services) -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = SheetApp::new(session, services); app.ui.ribbon_tab = "Automate".into(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( |ui, app: &mut SheetApp| { @@ -280,7 +284,7 @@ fn a_new_macro_never_replaces_one_with_its_name() { { let ed = h.state_mut().macros.editor.as_mut().unwrap(); ed.draft.name = "three".into(); - ed.draft.steps = vec![gridcraft_engine::model::Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; + ed.draft.steps = vec![Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; ed.dirty = true; } h.run_steps(2); @@ -306,7 +310,7 @@ fn a_rename_that_lands_before_a_failed_save_is_remembered() { let ed = h.state_mut().macros.editor.as_mut().unwrap(); ed.draft.name = "Renamed".into(); // Too large to keep in the workbook: the rename lands, the save after it fails. - ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A4", "input": big}))); + ed.draft.steps.push(Step::command("cell.set", json!({"cell": "A4", "input": big}))); ed.dirty = true; } h.run_steps(2); @@ -366,7 +370,7 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { // A new macro whose one step writes A1, not saved yet. h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"})); h.run_steps(3); - let step = gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); + let step = Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step]; let book1 = h.state().session.active().unwrap().uid; // Another workbook becomes active while the editor is open (File › New, Switch Window, an agent). @@ -406,6 +410,55 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } +#[test] +fn a_deferred_close_acts_on_the_editors_window_whichever_window_is_active_meanwhile() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book1 = h.state().session.active().unwrap().uid; + let home = h.state().session.active_window().unwrap(); + // Another workbook in a window of its own. + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(2); + let book2 = h.state().session.active().unwrap().uid; + let other = h.state().session.active_window().unwrap(); + assert_ne!(other, home); + // Back in the editor's window, File › Close asks about the draft and waits. + h.state_mut().enter_window(home); + h.run_steps(2); + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + assert_eq!(h.state().macros.editor.as_ref().and_then(|e| e.then_close.as_ref()).map(|c| c.window), Some(home)); + // The other window is the session's active one when the question is answered (a native + // window the person clicked on while the question stood), while the editor's window draws + // the question: the answer is given through the editor's own frame, as its button gives it. + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let mut d = app.dialog.take().expect("the editor is its window's dialog"); + assert_eq!(d.name(), "macroEditor"); + app.session.activate_window(other); + assert_eq!(app.session.active().map(|d| d.uid), Some(book2)); + // Don't Save: the question's answer closes the editor without saving. + let ed = app.macros.editor.as_mut().unwrap(); + ed.ask_close = false; + ed.dirty = false; + ed.close_requested = true; + let mut open = true; + let mut out = ctx.run_pass(egui::RawInput::default(), |ctx| gridcraft_ui_egui::macro_editor::window(app, ctx, &mut d, &mut open)); + out.textures_delta.clear(); // not painted + assert!(!open, "the editor closed"); + // The close that was asked in the editor's window closes that workbook, not the other. + assert!(!is_open(&h, book1), "the editor's workbook closed"); + assert!(is_open(&h, book2), "the workbook of the window activated meanwhile stays open"); + assert!(h.state().session.window(other).is_some()); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + h.run_steps(2); + assert!(h.state().dialog.is_none()); +} + /// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the /// app answered with over the next frames. fn request_window_close(h: &mut Harness) -> Vec { @@ -552,3 +605,436 @@ fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { assert!(ms < 10_000.0, "five frames took {ms} ms"); assert!(h.query_by_label_contains("Step 1: ").is_some()); } + +/// Opens the editor on a new macro and gives its draft a step, as editing it does. +fn edited_new_macro(h: &mut Harness) { + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.steps.push(Step::command("cell.set", json!({"cell": "A1", "input": "7"}))); + ed.dirty = true; + h.run_steps(2); +} + +/// Clicks a button of the editor's Save changes question: the question's buttons are drawn last +/// (the formula bar has a Cancel and the editor a Save of their own). +fn answer(h: &mut Harness, label: &str) { + h.get_all_by_role_and_label(egui::Role::Button, label).last().unwrap().click(); + h.run_steps(4); +} + +/// Whether the workbook `uid` is still open (the app starts a blank one when the last closes). +fn is_open(h: &Harness, uid: u64) -> bool { + h.state().session.documents().iter().any(|d| d.uid == uid) +} + +fn asks_about(h: &Harness, name: &str) -> bool { + h.query_by_label_contains(&format!("Save changes to macro “{name}”")).is_some() +} + +#[test] +fn closing_the_workbook_asks_about_an_unsaved_draft_first() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book = h.state().session.active().unwrap().uid; + assert!(!h.state().session.active().unwrap().is_dirty()); + // The workbook is saved, but the draft is not: File › Close asks about the draft and waits. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + assert!(is_open(&h, book)); + // Cancel keeps the workbook and the draft. + answer(&mut h, "Cancel"); + assert!(is_open(&h, book)); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + // A window's close button (`window.close`) asks the same way. + let window = h.state().session.active_window().unwrap(); + let r = h.state_mut().run("window.close", json!({"window": window})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + answer(&mut h, "Cancel"); + assert!(is_open(&h, book)); + // Don't Save closes the editor, then the workbook (which has nothing of its own to ask about). + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(!is_open(&h, book)); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn save_in_the_question_saves_the_macro_then_the_workbook_asks_about_itself() { + let path = std::env::temp_dir().join(format!("gridcraft-macro-draft-{}.xlsx", std::process::id())); + let _ = std::fs::remove_file(&path); + let target = path.to_string_lossy().to_string(); + let mut h = harness_with(Services { pick_save: Some(Box::new(move |_| Some(target.clone()))), ..Default::default() }); + edited_new_macro(&mut h); + let book = h.state().session.active().unwrap().uid; + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + answer(&mut h, "Save"); + // The macro is in the workbook now, which makes it unsaved: closing it goes on and asks. + assert!(h.state().macros.editor.is_none()); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + h.get_all_by_role_and_label(egui::Role::Button, "Save").last().unwrap().click(); + h.run_steps(4); + assert!(!is_open(&h, book)); + let mut s = Session::new(); + s.execute("file.open", json!({"path": path.to_str().unwrap()})).unwrap(); + let wb = &s.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!(wb.macros[0].steps.len(), 1); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_draft_that_cannot_be_saved_keeps_its_workbook_open() { + let mut h = harness(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Taken", "steps": []}})).unwrap(); + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + edited_new_macro(&mut h); + let book = h.state().session.active().unwrap().uid; + // A new macro never replaces one with its name, so Save is refused. + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into(); + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + answer(&mut h, "Save"); + assert!(h.state().message.is_some()); + h.state_mut().message = None; + h.run_steps(2); + assert!(h.state().macros.editor.is_some()); + // Closing the editor itself later never closes the workbook the earlier question was about. + h.state_mut().macros.editor.as_mut().unwrap().close_requested = true; + h.run_steps(3); + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(is_open(&h, book)); +} + +#[test] +fn an_untouched_new_macro_does_not_make_closing_ask() { + let mut h = harness(); + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + assert!(h.state().macros.editor.as_ref().unwrap().dirty); + let book = h.state().session.active().unwrap().uid; + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], true); + assert!(!is_open(&h, book)); + h.run_steps(3); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn a_new_macros_name_change_alone_makes_closing_ask() { + let mut h = harness(); + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + let book = h.state().session.active().unwrap().uid; + // Only the name changes, as typing in the Name box does. + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "Renamed".into(); + ed.dirty = true; + h.run_steps(2); + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, "Renamed")); + assert!(is_open(&h, book)); +} + +/// Quit (Cmd+Q, the macOS Quit menu, a Dock quit) and the commands the app answered with over the +/// next frames. +fn request_quit(h: &mut Harness) -> Vec { + let ctx = h.ctx.clone(); + h.state_mut().request_quit(&ctx); + step_commands(h) +} + +#[test] +fn quit_asks_about_the_editors_unsaved_steps_first() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + assert!(!h.state().session.active().unwrap().is_dirty()); + // Every workbook is saved, but the draft is not: the app stays and the editor asks. + let sent = request_quit(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(!h.state().allow_close); + assert!(asks_about(&h, &name)); + // Cancel keeps the app and the draft. + answer(&mut h, "Cancel"); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + // Asked again, Don't Save closes the editor and the quit goes on. + request_quit(&mut h); + assert!(asks_about(&h, &name)); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn a_refused_save_while_quitting_stops_the_quit() { + let mut h = harness(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Taken", "steps": []}})).unwrap(); + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + edited_new_macro(&mut h); + // A new macro never replaces one with its name, so the question's Save is refused. + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into(); + for quit in [request_quit, request_window_close] { + h.run_steps(2); + let sent = quit(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose) || !h.state().allow_close, "{sent:?}"); + assert!(asks_about(&h, "Taken")); + answer(&mut h, "Save"); + assert!(h.state().message.is_some()); + h.state_mut().message = None; + assert!(h.state().macros.editor.is_some()); + // Closing the editor later, on its own, no longer quits the app. + h.state_mut().macros.editor.as_mut().unwrap().close_requested = true; + h.run_steps(3); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(!sent.contains(&egui::ViewportCommand::Close), "the app keeps running: {sent:?}"); + assert!(!h.state().allow_close); + // The next round starts with the same draft in the editor again. + edited_new_macro(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into(); + } +} + +#[test] +fn an_untouched_new_macro_closes_without_asking_however_the_editor_closes() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let open_new = |h: &mut Harness| { + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + assert!(h.state().macros.editor.as_ref().is_some_and(|e| e.dirty && e.saved_as.is_none())); + }; + // Its own close button. + open_new(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().close_requested = true; + h.run_steps(3); + assert!(h.state().macros.editor.is_none(), "no Save changes question for a macro nobody edited"); + // The control channel's cancel. + open_new(&mut h); + let r = gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({})); + assert!(matches!(&r, gridcraft_ui_egui::control::Outcome::Done(v) if v.get("error").is_none())); + h.run_steps(2); + assert!(h.state().macros.editor.is_none()); + // Quit, with every workbook saved: the editor closes and the quit goes on. + open_new(&mut h); + let sent = request_quit(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn closing_the_window_that_shows_the_editor_asks_about_its_draft() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + let other = h.state().session.active_window().unwrap(); + assert_ne!(other, home); + // The workbook stays open in its other window, but the editor's window would take the draft. + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert_eq!(h.state().session.active_window(), Some(home), "the editor's window comes forward to ask"); + assert!(asks_about(&h, &name)); + answer(&mut h, "Cancel"); + assert!(h.state().session.window(home).is_some()); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + // File › Close in the editor's window asks too; Don't Save closes that window and the editor. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().session.window(home).is_none()); + assert!(h.state().session.window(other).is_some(), "the workbook stays open in its other window"); +} + +#[test] +fn closing_the_window_that_shows_a_saved_editor_closes_the_editor_with_it() { + let mut h = harness(); + three(&mut h); + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], true); + h.run_steps(3); + // Nothing is left of an editor no window shows: a new one opens, and nothing was lost. + assert!(h.state().macros.editor.is_none()); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); +} + +#[test] +fn a_close_request_during_the_editors_save_dialog_waits_for_it() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book1 = h.state().session.active().unwrap().uid; + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(2); + let book2 = h.state().session.active().unwrap().uid; + h.state_mut().enter_window(home); + h.run_steps(2); + // Cmd+W: the editor asks about the draft and the close waits. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + // Cmd+W again, then the window's close button, while the question stands: the same close, + // so both wait on the same question; it is not asked twice and nothing closes under it. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert_eq!(h.get_all_by_label_contains("Save changes to macro").count(), 1, "one question"); + assert!(is_open(&h, book1) && is_open(&h, book2)); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + let pending = h.state().macros.editor.as_ref().and_then(|e| e.then_close.as_ref()).expect("the close waits"); + assert_eq!((pending.id.as_str(), pending.whole), ("file.close", true), "the close that opened the question is the pending one"); + // Don't Save: the editor closes and the workbook closes once; the other workbook stays. + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(!is_open(&h, book1)); + assert!(is_open(&h, book2), "the close ran once"); + assert!(h.state().dialog.is_none()); + assert!(h.state().message.is_none(), "{:?}", h.state().message); +} + +#[test] +fn a_different_close_during_the_editors_question_is_refused_until_it_is_answered() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book = h.state().session.active().unwrap().uid; + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + let other = h.state().session.active_window().unwrap(); + // The editor's window closes while the workbook stays open in the other: the editor asks. + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + // The Backstage Close (the whole workbook) while that question stands asks for more than + // the question was asked for: refused with the reason; the question and the workbook stay. + let e = h.state_mut().run("file.close", json!({"document": true})).unwrap_err(); + assert!(e.contains("asking whether to save") && e.contains("its window closes"), "{e}"); + h.run_steps(2); + assert_eq!(h.get_all_by_label_contains("Save changes to macro").count(), 1, "one question"); + assert!(is_open(&h, book)); + assert_eq!(h.state().session.windows().len(), 2); + // Don't Save closes the editor's window alone, as the question was asked for. + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().session.window(home).is_none()); + assert!(h.state().session.window(other).is_some()); + assert!(is_open(&h, book), "the workbook stays open in its other window"); + // The reverse: the workbook is closing when a close of the editor's window alone arrives. + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + h.state_mut().enter_window(other); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let r = h.state_mut().run("file.close", json!({"document": true})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + let e = h.state_mut().run("window.close", json!({"window": other})).unwrap_err(); + assert!(e.contains("the workbook closes"), "{e}"); + // Cancel keeps the workbook, both windows and the draft. + answer(&mut h, "Cancel"); + assert!(is_open(&h, book)); + assert_eq!(h.state().session.windows().len(), 2); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + assert!(h.state().macros.editor.as_ref().is_some_and(|e| e.then_close.is_none())); +} + +#[test] +fn quitting_with_a_draft_in_one_window_and_unsaved_cells_in_another_asks_in_order_and_cancel_stops_it() { + let mut h = harness(); + // Book1, saved, with an unsaved draft in its window; Book2 with unsaved cells in its own. + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book1 = h.state().session.active().unwrap().uid; + let a = h.state().session.active_window().unwrap(); + assert!(!h.state().session.active().unwrap().is_dirty()); + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.run_steps(2); + let book2 = h.state().session.active().unwrap().uid; + let title2 = h.state().session.active().unwrap().display_title(); + let b = h.state().session.active_window().unwrap(); + assert_ne!(a, b); + let both_open = + |h: &Harness| is_open(h, book1) && is_open(h, book2) && h.state().session.window(a).is_some() && h.state().session.window(b).is_some(); + // Quit from Book2's window: the editor's question comes first, in its own window. + let sent = request_quit(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}"); + assert_eq!(h.state().session.active_window(), Some(a), "the editor's window comes forward"); + assert!(asks_about(&h, &name)); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no workbook is asked about yet"); + // Don't Save: the draft goes, the editor closes and the quit goes on (the window asks to + // close again, which a test harness has to do by hand). + h.get_all_by_role_and_label(egui::Role::Button, "Don't Save").last().unwrap().click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + request_window_close(&mut h); + // Book2 is asked about next, in its window, as the quit's question. + assert_eq!(h.state().session.active_window(), Some(b), "the unsaved workbook's window comes forward"); + let d = h.state().dialog.as_ref().expect("Book2 is asked about"); + assert_eq!(d.name(), "saveChanges"); + assert_eq!(d.values.get("title").and_then(serde_json::Value::as_str), Some(title2.as_str())); + assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values); + assert!(both_open(&h)); + // Cancel: the quit stops with both windows and workbooks open and Book2's changes kept; the + // draft the person discarded stays discarded. + h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click(); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}"); + assert!(h.state().dialog.is_none()); + assert!(both_open(&h)); + assert!(h.state().session.documents().iter().find(|d| d.uid == book2).unwrap().is_dirty()); + assert!(h.state().macros.editor.is_none()); + // A quit cancelled at the editor's question stops before any workbook is asked about. + h.state_mut().enter_window(a); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let sent = request_quit(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(asks_about(&h, &name)); + answer(&mut h, "Cancel"); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no Save Changes question followed"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1), "the draft is kept"); + assert!(both_open(&h)); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a70dc449b..f06d29b95 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -14,6 +14,7 @@ mod borders; mod cell_editor_caret; mod cell_pictures; mod cf_rule_pick; +mod chart_data_table; mod chart_resize; mod chart_source_layering; mod clipboard; @@ -74,6 +75,7 @@ mod open_encrypted; mod outline; mod page_layout_view; mod page_setup_margins; +mod page_setup_page_order; mod paste_names; mod paste_special_dialog; mod pivot_pane; diff --git a/crates/ui-egui/tests/it/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs index 79b36f897..bf3052b5a 100644 --- a/crates/ui-egui/tests/it/page_setup_margins.rs +++ b/crates/ui-egui/tests/it/page_setup_margins.rs @@ -25,9 +25,9 @@ fn page_setup_ok_without_edits_keeps_custom_margins() { let mut app = app_with_custom_margins(); app.open_dialog("pageSetup", params); let d = app.dialog.take().unwrap(); - let shown = json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2}); + let shown = json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2, "pageOrder": "downThenOver"}); assert_eq!(Value::Object(d.values.clone()), shown); - app.run("pageLayout.margins", Value::Object(d.values)).unwrap(); + app.run(d.command.unwrap(), Value::Object(d.values)).unwrap(); assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]); } } @@ -38,6 +38,6 @@ fn page_setup_one_edited_field_keeps_the_other_margins() { app.open_dialog("pageSetup", json!({})); let mut d = app.dialog.take().unwrap(); d.values.insert("left".into(), json!(1.5)); - app.run("pageLayout.margins", Value::Object(d.values)).unwrap(); + app.run(d.command.unwrap(), Value::Object(d.values)).unwrap(); assert_eq!(margins(&app), [1.5, 2.5, 3.0, 0.25, 0.1, 0.2]); } diff --git a/crates/ui-egui/tests/it/page_setup_page_order.rs b/crates/ui-egui/tests/it/page_setup_page_order.rs new file mode 100644 index 000000000..2d797d4c9 --- /dev/null +++ b/crates/ui-egui/tests/it/page_setup_page_order.rs @@ -0,0 +1,111 @@ +//! Page Setup › Page order: the dialog shows the sheet's order, and OK sets the chosen one with the +//! margins in one undo step, so printing goes down, then over or over, then down. + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::model::PageOrder; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use gridcraft_ui_egui::i18n::Language; +use serde_json::{Value as Json, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Four pages, two tall and two wide: print area A1:D4 with breaks before row 3 and column C. +fn harness() -> Harness { + let mut h = crate::support::blank_harness(4); + let app = h.state_mut(); + app.run("range.setValues", json!({"range": "A1", "values": [["red", null, "blue"], [], ["green", null, "yellow"]]})).unwrap(); + app.run("pageLayout.pageSetup", json!({"printArea": "A1:D4", "rowBreaks": [3], "colBreaks": ["C"], "left": 1.25})).unwrap(); + h.run_steps(2); + h +} + +fn order(h: &Harness) -> (PageOrder, f32) { + let print = &h.state().session.active().unwrap().wb.active().unwrap().print; + (print.page_order, print.margins[0]) +} + +/// The printed pages' ranges, in print order. +fn pages(h: &mut Harness) -> Vec { + let pv = h.state_mut().run("file.printPreview", json!({})).unwrap(); + pv["pageList"].as_array().unwrap().iter().map(|p| p["range"].as_str().unwrap().to_string()).collect() +} + +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + } +} + +#[test] +fn page_setup_sets_the_page_order() { + let mut h = harness(); + assert_eq!(pages(&mut h), ["A1:B2", "A3:B4", "C1:D2", "C3:D4"]); + // Page Layout › Print Titles opens Page Setup on its Sheet tab. + h.state_mut().open_dialog("pageSetup", json!({"tab": "Sheet"})); + h.run_steps(3); + assert!(h.query_by_label("Page order").is_some()); + assert_eq!(h.state().dialog.as_ref().unwrap().values["pageOrder"], "downThenOver"); + h.get_by_label("Over, then down").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values["pageOrder"], "overThenDown"); + h.get_by_label_contains("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + assert_eq!(order(&h), (PageOrder::OverThenDown, 1.25), "the order is set and the margins kept"); + assert_eq!(pages(&mut h), ["A1:B2", "C1:D2", "A3:B4", "C3:D4"]); + // Opened again, it shows the sheet's order. + h.state_mut().open_dialog("pageSetup", json!({})); + assert_eq!(h.state().dialog.as_ref().unwrap().values["pageOrder"], "overThenDown"); + h.state_mut().dialog = None; + // One undo step takes it back. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(order(&h), (PageOrder::DownThenOver, 1.25)); + assert_eq!(pages(&mut h), ["A1:B2", "A3:B4", "C1:D2", "C3:D4"]); +} + +#[test] +fn confirming_page_setup_sets_the_order_and_keeps_margins_at_zero_or_more() { + let mut h = harness(); + control(&mut h, "ui.dialog", json!({"name": "pageSetup"})); + control(&mut h, "ui.dialog.set", json!({"field": "pageOrder", "value": "overThenDown"})); + // A margin dragged below zero is zero, as with the Margins command. + control(&mut h, "ui.dialog.set", json!({"field": "left", "value": -0.5})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(order(&h), (PageOrder::OverThenDown, 0.0)); + // A bad order is refused and the dialog stays open. + control(&mut h, "ui.dialog", json!({"name": "pageSetup"})); + control(&mut h, "ui.dialog.set", json!({"field": "pageOrder", "value": "sideways"})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(h.state().dialog.is_some()); + assert_eq!(order(&h).0, PageOrder::OverThenDown); +} + +#[test] +fn page_order_is_translated_where_page_setup_is() { + let mut h = harness(); + h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); + h.state_mut().open_dialog("pageSetup", json!({"tab": "Sheet"})); + h.run_steps(3); + for (english, german) in [ + ("Page order", "Seitenreihenfolge"), + ("Down, then over", "Seiten nach unten, dann nach rechts"), + ("Over, then down", "Seiten nach rechts, dann nach unten"), + ] { + assert!(h.query_by_label(german).is_some(), "no {german}"); + assert!(h.query_by_label(english).is_none(), "{english} is untranslated"); + } + // Every catalog with the dialog's margin labels has the page order too. + for language in Language::ALL { + if language.tr("Left margin (in):") == "Left margin (in):" { + continue; + } + for label in ["Page order", "Down, then over", "Over, then down"] { + assert_ne!(language.tr(label), label, "{language:?}: {label}"); + } + } +} diff --git a/crates/ui-egui/tests/it/wheel_zoom.rs b/crates/ui-egui/tests/it/wheel_zoom.rs index 634bc68ea..727e55020 100644 --- a/crates/ui-egui/tests/it/wheel_zoom.rs +++ b/crates/ui-egui/tests/it/wheel_zoom.rs @@ -276,3 +276,26 @@ fn page_and_point_units_scroll_the_grid_by_their_converted_distance() { let point = h.state().view().scroll.y; assert!(point > 0.0 && point < page, "40 points scroll less than a page: point {point}, page {page}"); } + +#[test] +/// Zooming moves no cell: trace arrows shown before a Ctrl+wheel zoom are still shown after its +/// glide (#3097), while an edit still removes them. +fn trace_arrows_stay_through_a_wheel_zoom() { + let mut h = animated(); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + h.state_mut().run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "F7"})).unwrap(); + let r = h.state_mut().run("formulas.tracePrecedents", json!({})).unwrap(); + let app = h.state_mut(); + app.grid.trace = gridcraft_ui_egui::grid::Trace::new(app, r); + let current = |h: &Harness| { + let d = h.state().session.active().unwrap(); + h.state().grid.trace.as_ref().is_some_and(|t| (t.uid, t.revision) == (d.uid, d.revision)) + }; + assert!(current(&h)); + let seen = turn_and_watch(&mut h, 1.0, 40); + assert!(seen.windows(2).filter(|w| w[0] != w[1]).count() > 1, "the zoom glides over several frames: {seen:?}"); + assert!(current(&h), "the arrows stay at {}%", zoom(&h)); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "2"})).unwrap(); + assert!(!current(&h), "an edit removes them"); +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 37bcf91b0..6fa9e0144 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -268,6 +268,12 @@ pub fn read_chart(root: &El) -> Option { series, legend, data_labels, + data_table: plot.child("dTable").map(|t| gridcraft_model::ChartDataTable { + show_keys: bool_val(t, "showKeys"), + horizontal_border: bool_val(t, "showHorzBorder"), + vertical_border: bool_val(t, "showVertBorder"), + outline: bool_val(t, "showOutline"), + }), gridlines: val_axes.iter().any(|a| a.child("majorGridlines").is_some()), style: root.child_val("style").and_then(|v| v.parse().ok()).unwrap_or(2), x_title: x_ax.and_then(|a| a.child("title")).and_then(rich_title), @@ -866,6 +872,16 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart, book_name: &str) -> Str s.push_str(&axis_xml(&Ax { cat: true, id: 3, cross: 4, pos: "b", delete: true, grid: false, title: None, crosses_max: false })); s.push_str(&axis_xml(&Ax { cat: false, id: 4, cross: 3, pos: "r", delete: false, grid: false, title: None, crosses_max: true })); } + if let Some(t) = &ch.data_table { + let _ = write!( + s, + "", + u8::from(t.horizontal_border), + u8::from(t.vertical_border), + u8::from(t.outline), + u8::from(t.show_keys) + ); + } s.push_str(""); let pos = match ch.legend { LegendPos::None => None, diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs index 4ed0d0820..ce71d4f32 100644 --- a/crates/xlsx/src/chartex.rs +++ b/crates/xlsx/src/chartex.rs @@ -130,6 +130,7 @@ pub fn read_chartex(root: &El) -> Option { series, legend, data_labels, + data_table: None, gridlines: plot.kids("axis").any(|a| a.child("majorGridlines").is_some()), style: 2, x_title: axis_title("0"), diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index ea35903ea..0dae14344 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -8,8 +8,8 @@ use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRe use gridcraft_formula::Expr; use gridcraft_model::{ AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, - NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, - ValidationKind, XmlColumnPr, + NoteBox, NoteCorner, Orientation, PageOrder, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, + Validation, ValidationKind, XmlColumnPr, }; use crate::IoError; @@ -206,6 +206,8 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o if let Some((w, h)) = p.fit_to { let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\""); } + // Only "over, then down" is written, as Excel does: `downThenOver` is the schema default. + if p.page_order != PageOrder::DownThenOver { + let _ = write!(s, " pageOrder=\"{}\"", p.page_order.id()); + } let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" }); if let Some(id) = kept.page_setup_id() { let _ = write!(s, " r:id=\"{id}\""); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 8d7bc6572..c03ff01bc 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -5,6 +5,7 @@ mod cellmetadata; mod chart_book_names; mod chart_bool_default; mod chart_combo_kinds; +mod chart_data_table; mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; @@ -17,6 +18,7 @@ mod malformed; mod multilevel_category_roundtrip; mod note_box; mod ods_tests; +mod page_order; mod passthrough_tests; mod phonetic_tests; mod pivot_tests; diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs index 4c1af5158..14dc9d243 100644 --- a/crates/xlsx/src/tests/chart_book_names.rs +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -75,6 +75,7 @@ fn chart_names_are_written_qualified() { ], legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, diff --git a/crates/xlsx/src/tests/chart_data_table.rs b/crates/xlsx/src/tests/chart_data_table.rs new file mode 100644 index 000000000..7c22c9307 --- /dev/null +++ b/crates/xlsx/src/tests/chart_data_table.rs @@ -0,0 +1,46 @@ +//! Synthetic DrawingML only: table flags, default-valued booleans and full XLSX round trips. + +use crate::{chart::read_chart, read_xlsx, write_xlsx, xml::parse}; +use gridcraft_model::ChartDataTable; + +fn chart(table: &str) -> gridcraft_model::Chart { + let xml = format!( + "\ + \ + General12\ + {table}" + ); + read_chart(&parse(xml.as_bytes()).unwrap()).unwrap() +} + +#[test] +fn data_table_boolean_defaults_and_absence() { + assert_eq!(chart("").data_table, None); + assert_eq!( + chart("").data_table, + Some(ChartDataTable { show_keys: false, horizontal_border: false, vertical_border: false, outline: false }) + ); + assert_eq!( + chart("").data_table, + Some(ChartDataTable::default()) + ); +} + +#[test] +fn every_data_table_flag_combination_survives_xlsx_save_reopen() { + for flags in 0..16u8 { + let settings = + ChartDataTable { show_keys: flags & 1 != 0, horizontal_border: flags & 2 != 0, vertical_border: flags & 4 != 0, outline: flags & 8 != 0 }; + let mut c = chart(""); + c.id = 1; + c.data_table = Some(settings.clone()); + let mut wb = gridcraft_model::Workbook::new(); + wb.sheet_mut(0).unwrap().charts.push(c); + let bytes = write_xlsx(&wb).unwrap(); + let (back, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.sheets[0].charts[0].data_table.as_ref(), Some(&settings), "flags {flags}"); + let bytes = write_xlsx(&back).unwrap(); + let (again, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(again.sheets[0].charts[0].data_table.as_ref(), Some(&settings)); + } +} diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 6b9dff341..4de0be3c3 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -548,6 +548,7 @@ fn chartex_parts_as_excel_expects_them() { }], legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs index e9f6a4461..3a6f9f712 100644 --- a/crates/xlsx/src/tests/literal_category_roundtrip.rs +++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs @@ -41,6 +41,7 @@ fn literal_category_with_separators_survives_save() { series, legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs index 730edc8a6..7a375a438 100644 --- a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs +++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs @@ -41,6 +41,7 @@ fn workbook(categories: &str, values: &str) -> Workbook { }], legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, diff --git a/crates/xlsx/src/tests/page_order.rs b/crates/xlsx/src/tests/page_order.rs new file mode 100644 index 000000000..f7aa40eb1 --- /dev/null +++ b/crates/xlsx/src/tests/page_order.rs @@ -0,0 +1,63 @@ +//! Page Setup › Sheet › Page order: `` is read and written back. As in +//! Excel's files, only "over, then down" is written; "down, then over" is the schema default. + +use gridcraft_model::{PageOrder, Workbook}; + +use super::minimal; +use crate::{read_xlsx, write_xlsx}; + +/// The first sheet's XML as written. +fn sheet_xml(bytes: &[u8]) -> String { + String::from_utf8(crate::package::Package::open(bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap() +} + +/// The `` element of a sheet's XML. +fn page_setup(xml: &str) -> &str { + let start = xml.find("").unwrap() + 2; + &xml[start..start + len] +} + +#[test] +fn over_then_down_is_read_and_written_back() { + // The attributes Excel writes for a sheet set to Over, then down. + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().print.page_order, PageOrder::OverThenDown); + let bytes = write_xlsx(&wb).unwrap(); + let xml = sheet_xml(&bytes); + let ps = page_setup(&xml); + assert_eq!(ps, r#""#); + let (back, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.sheet(0).unwrap().print.page_order, PageOrder::OverThenDown); + // CT_PageSetup's attribute order: Fit To before the page order, the page order before the + // orientation. + let mut wb = back; + wb.sheet_mut(0).unwrap().print.fit_to = Some((1, 2)); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(page_setup(&xml).contains(r#"fitToHeight="2" pageOrder="overThenDown" orientation="portrait""#), "{xml}"); +} + +#[test] +fn down_then_over_writes_no_page_order() { + // Written out, absent, unknown (read as the default) and a sheet without page setup at all. + for body in [ + r#""#, + r#""#, + r#""#, + "", + ] { + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().print.page_order, PageOrder::DownThenOver, "{body}"); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(!xml.contains("pageOrder"), "{body}: {xml}"); + assert!(xml.matches(" Workbook { even_footer: "&R&A".into(), hf_scale_with_doc: false, hf_align_with_margins: true, + page_order: PageOrder::OverThenDown, }; s.sparklines.push(Sparkline { cell: at("J2"), source: "B2:B3".into(), kind: SparklineKind::WinLoss, color: Color::Rgb(0x112233), markers: true }); s.tables.push(Table { @@ -407,6 +408,7 @@ fn sample() -> Workbook { }], legend: LegendPos::Bottom, data_labels: true, + data_table: None, gridlines: true, style: 2, x_title: Some("Region".into()), diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs index 0b4ebcef8..1a418bb51 100644 --- a/crates/xlsx/src/tests/smooth_roundtrip.rs +++ b/crates/xlsx/src/tests/smooth_roundtrip.rs @@ -29,6 +29,7 @@ fn smooth_line_setting_round_trips() { series: vec![base_series.clone()], legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, @@ -47,6 +48,7 @@ fn smooth_line_setting_round_trips() { series: vec![smooth_series], legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs index cb969a389..2a1e19d05 100644 --- a/crates/xlsx/src/tests/trendline_roundtrip.rs +++ b/crates/xlsx/src/tests/trendline_roundtrip.rs @@ -53,6 +53,7 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec) -> Chart { }], legend: LegendPos::Bottom, data_labels: false, + data_table: None, gridlines: true, style: 2, x_title: None, diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 89358dd30..d348f5f71 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-01 · **Change:** minor (chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **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). @@ -49,7 +49,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | all six types, several per series, name, forward/backward forecast, set intercept, equation and R² label, line colour/width/dash; read, written and drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `trendline` adds/removes them. No Format Trendline pane, label position/number format not kept | | Error bars | fixed, %, standard deviation/error, custom | **missing** | | Up/down bars, high-low lines, drop lines, series lines | yes | **missing** | -| Data table under the chart | yes | **missing** | +| Data table under the chart | yes | yes for column, bar, line, area and compatible combo charts: live formatted values, blank cells, grouped category headers, optional legend keys, horizontal/vertical borders and outline; native `c:dTable` XLSX round-trip; shared UI/print/PDF rendering | | Per-series / per-point fill, line, marker, effects | Format pane for every element | series colour only; Format Chart pane covers chart-level options | | Chart styles and colour palettes | ~16 styles × colour sets | style number stored, own palettes | | Chart sheets | yes | **kept, not editable**: not shown, and written back unchanged in their place on save (#727); left out when a sheet they plot is deleted or renamed. No F11 / Move Chart > New sheet | @@ -62,6 +62,20 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | PivotCharts | yes | Insert ▸ PivotChart (`insert.pivotChart`): bound to a PivotTable's report, rebuilt on refresh and layout changes, `c:pivotSource` round trip; no field buttons, innermost category level only | | Animation on data change | yes (Windows) | no | +Data tables are controlled by `chart.addElement {element: "dataTable", on: true, + dataTableKeys: true}` or `chart.set {dataTable: true}`. `chart.set` also accepts +`dataTableKeys`, `dataTableHorizontalBorder`, `dataTableVerticalBorder` and `dataTableOutline`. +Changing to an unsupported chart kind removes the table (Undo restores it). The chart legend +is independent. Bar charts keep their vertical category axis; other supported charts use the +table headers instead of repeating category-axis labels. Individual labels and values that +exceed a cell's width are shortened with an ellipsis. Tables are bounded to 128 categories, +32 series and half the available chart height; if they cannot fit, a “Data table needs more +space” notice replaces the table. Enlarge the chart or reduce its source range. Table-specific +font/fill styling and exact Excel layout fidelity are not implemented; Excel-side verification +has not been performed. + +Format reference: [Microsoft's DrawingML data table documentation](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.charts.datatable?view=openxml-3.0.1). + ## XLSX round-trip Charts are read from and written to DrawingML chart parts, but only the fields the model holds @@ -91,6 +105,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Chart data tables for column, bar, line, area and compatible combo charts: command/UI controls, formatted source values, blanks, grouped category headers, legend keys, independent borders, shared print/PDF rendering and native c:dTable import/export. Dense tables show a space notice; no readiness score change. | | 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) | | 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) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5479fc5a4..5aeb04848 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -118,7 +118,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real |---|---|---|---| | PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. Only the 14 standard PDF fonts, no embedding: **non-Latin text (CJK, Arabic, Devanagari, Thai) does not export**; web build exports without a file prompt (#141) | | XPS | Windows | no | Low priority | -| Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | | +| Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | Pages print in the sheet's page order: down, then over (the default) or over, then down | | Images of ranges/charts (Copy as Picture) | yes | chart raster in `crates/chart` | No range-to-image command | ## Import sources (Get & Transform) @@ -155,7 +155,9 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-11-01 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | +| 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | +| 2026-11-01 | minor | Chart data tables preserve showKeys, showHorzBorder, showVertBorder and showOutline through native c:dTable XLSX import/export; synthetic tests cover all 16 flag combinations and boolean defaults. Excel-side verification remains outstanding. | +| 2026-11-01 | minor | XLSX reads and writes Page Setup's page order: `` for Over, then down, and no attribute for Down, then over (the default), as Excel writes them; PDF export and printing number and order the pages by it | | 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | | 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | diff --git a/docs/gaps.md b/docs/gaps.md index 85b26a355..7604b3984 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (§13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (§13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **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 @@ -44,6 +44,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 4. Charts lose options on round trip; shallow chart model (F, FF) - **Missing:** axis scaling, label options, error bars, per-point formatting. +- **Added:** data tables beneath column, bar, line, area and compatible combo charts, with legend keys, border controls, live source values, native XLSX round-trip and print/PDF. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back. (`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts @@ -117,8 +118,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) -- `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the - FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h. +- Forecast Sheet (`data.forecastSheet`) and the FORECAST.ETS family are in, including Forecast + Start (hindcast, #785). - Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and Limits reports. Report refusal leaves the solution, saved model and history unchanged; a @@ -126,9 +127,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- model on cells of its sheet and reads it back; Reset All removes it. Solver Options sets the evaluation limit, Integer Optimality and the Evolutionary options, which are saved with the model. AllDifferent constraints and the Sensitivity and Population reports remain. - The Analysis ToolPak - (`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the - FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h. +- The Analysis ToolPak (`data.analysisToolPak`) has all 19 of Excel's tools; their chart output + remains. **Estimate:** 12–20 h. ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. @@ -185,6 +185,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | §13: the Analysis ToolPak has all 19 of Excel's tools (two-factor Anova with and without replication, z-Test, Fourier Analysis, Random Number Generation, Sampling); its chart output remains. The stale "`data.solver`, `data.analysisToolPak` missing" and "no Forecast Start" sentences removed | +| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. | | 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model | | 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog | | 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` | diff --git a/docs/macros.md b/docs/macros.md index 3335b5ecc..1cd2cfbb5 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) +> **Last updated:** 2026-11-01 · **Change:** minor (a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -294,9 +294,21 @@ other macros open; a macro that was left out is not in the saved file. the Output tab sets the anchor and what to do when results would land on data. The editor belongs to the workbook it opened in: while another workbook is active it says so and Save, Check, Run and Debug wait until that workbook is active again, and closing the workbook closes - the editor. It keeps its window until it closes itself: another dialog asked for meanwhile is - refused with a message, and closing the app first asks about the editor's debug run and unsaved - steps, then about unsaved workbooks. + the editor. A draft with unsaved changes counts as unsaved work when its workbook closes (File > + Close, Cmd+W, a window's close button), or the window that shows the editor closes while the + workbook stays open in another: the editor first asks Save changes to the macro, even if the + workbook itself is saved; Save or Don't Save goes on with the close, in the window it was asked + in (whichever window was activated meanwhile), and a workbook that closes then asks about its + own unsaved changes, and Cancel keeps both open. While the question stands, a further request + for the same close (Cmd+W again, the window's close button) waits for the answer without a + second question, and the close then runs once; a request for a different close (the whole + workbook while only the editor's window was closing, or the reverse) is refused with the reason + until the question is answered. A new macro that has not been edited does not ask. It keeps its + window until it closes itself: another dialog asked for meanwhile is refused with a message, and + closing the app (its window's close button, Quit, Cmd+Q) first asks about the editor's debug + run and unsaved steps, in the editor's window, then about each unsaved workbook in its window; + Cancel at any of these questions stops the quit with every window open (a draft already + discarded with Don't Save stays discarded). - **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 58d2d1e29..a8c478aff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -85,11 +85,11 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Advanced Filter copy-to preserves cells outside its result and rejects worksheet overflow (#1031). Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | -| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | -| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) | -| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model, Reset All and Solver Options; no Sensitivity report), Forecast Sheet. Missing: Analyze Data | +| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag, axis titles and chart data tables (legend keys, borders, XLSX round-trip and print/PDF); no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag, axis titles and chart data tables (legend keys, borders, XLSX round-trip and print/PDF); no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model, Reset All and Solver Options; no Sensitivity report), Forecast Sheet. Missing: Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | @@ -334,6 +334,9 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | What-if and analysis: Analysis ToolPak has all 19 tools (was 13 of 19; no charts); no score change | +| 2026-11-01 | minor | Charts: data tables for column, bar, line, area and compatible combo charts, with legend keys and borders; live source values, native XLSX round-trip and shared print/PDF rendering. Readiness estimates unchanged. | +| 2026-11-01 | minor | Page layout and print: Page Setup's page order (Down, then over or Over, then down), kept in XLSX; no score change | | 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | | 2026-10-31 | minor | What-if and analysis: Solver Options (no score change) | | 2026-10-31 | minor | What-if and analysis: Solver Load/Save Model and Reset All; Forecast Sheet moved from Missing to the list of tools, as gaps.md §13 records (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0236a79f..8834e0817 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **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 @@ -25,7 +25,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | 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 | +| Formulas › Error Checking arrow: Trace Error, Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking…, Trace Error and Circular References. Trace Error (`formulas.traceError`, 11-01) follows the active cell's error back through the first reference showing the same error (in a range, its first cell showing it) to the cell it starts in, and draws red arrows along the way and blue ones from that cell's own references, as measured in Excel; on a cell without an error it draws nothing and the arrows shown stay. Circular References 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; a new trace replaces the arrows shown, where Excel adds to them, and there is no Remove Precedent Arrows | | 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; 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 | @@ -159,7 +159,7 @@ keeps the original link and an edit can be undone in one step. Format Cells, Insert Function (search, Most Recently Used, All and each category), Find and Replace, Go To and Go To Special, Sort, -Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal +Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup (margins and the page order), Goal Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options, @@ -196,6 +196,10 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. | +| 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too | +| 2026-11-01 | minor | Formulas › Error Checking › Trace Error (`formulas.traceError`): red arrows from the cell an error starts in to the selected cell, through the first reference showing the same error at each step, and blue arrows from that cell's own references, as measured in Excel; the command returns the source and the arrows | +| 2026-11-01 | minor | Trace arrows stay when the sheet is zoomed (View › Zoom, the status bar, Ctrl+wheel), as in Excel; every zoom used to remove them (#3097) | | 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used |