Merge the updated Python cells branch (57d7ea9b, main 25881a8e) into the Python mode layer

Conflicts: theme.rs (main's trace-arrow tokens kept beside the Python tokens), ROADMAP.md and docs/ui-parity.md (header lines combined, both sides' rows kept).
This commit is contained in:
prxnvt
2026-11-02 02:52:48 -05:00
89 changed files with 4323 additions and 161 deletions
+7 -5
View File
@@ -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 mode in the formula bar; earlier: 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 mode in the formula bar; earlier: 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,8 +135,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-01 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
| 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 mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
| 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) |
@@ -252,8 +253,9 @@ 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 mode in the formula bar |
| 2026-11-01 | minor | Progress log: Python cells as data |
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
| 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 |
+1
View File
@@ -14,6 +14,7 @@ mod frame;
mod kinds;
mod legend;
mod lines;
mod table;
mod trend;
use cartesian::{cartesian, scatter};
+4 -1
View File
@@ -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<Vec<String>> =
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
+13 -4
View File
@@ -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<String>]),
/// 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<usize> = (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<String>, &[Vec<String>]) = 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;
}
+1 -1
View File
@@ -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),
+107
View File
@@ -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<Layout>, labels: &'a [String], outer: &'a [Vec<String>]) -> 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<Layout>) {
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);
}
}
+3
View File
@@ -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()),
+121
View File
@@ -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<f32> = 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);
}
}
File diff suppressed because it is too large Load Diff
+23 -5
View File
@@ -397,7 +397,11 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
/// when the reference it names changed, and text that doesn't parse is kept. A rewrite that would
/// when the reference it names changed, and text that doesn't parse is kept. A cell's cached
/// result is kept while its references still name the same data (a rename, a move); a reference
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
/// recalculation removes its spill. A rewrite that would
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
/// the whole edit, which then changes nothing: see [`check_python_fits`].
pub(crate) fn rewrite_python_refs(
@@ -407,35 +411,49 @@ pub(crate) fn rewrite_python_refs(
for si in 0..wb.sheets.len() {
let Some(sh) = wb.sheets.get(si) else { continue };
let host = sh.name.clone();
let mut new_code: Vec<(CellRef, String)> = Vec::new();
let mut new_code: Vec<(CellRef, String, bool)> = Vec::new();
for (c, cell) in sh.cells.iter() {
let Some(py) = &cell.py else { continue };
let mut lost = false;
let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
let expr = gridcraft_formula::parse(reference).ok()?;
let before = gridcraft_formula::print(&expr);
let after = gridcraft_formula::print(&f(&host, expr));
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
(after != before).then_some(after)
});
if let Some(code) = changed {
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
new_code.push((c, code));
new_code.push((c, code, lost));
}
}
if new_code.is_empty() {
continue;
}
let Some(sh) = wb.sheet_mut(si) else { continue };
for (c, code) in new_code {
for (c, code, lost) in new_code {
if let Some(cell) = sh.cells.get_mut(c)
&& let Some(py) = cell.py.as_mut()
{
std::sync::Arc::make_mut(py).code = code;
let py = std::sync::Arc::make_mut(py);
py.code = code;
if lost {
py.output = None;
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
}
}
}
}
Ok(())
}
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
/// for any other form (a name, an error).
fn area_shape(reference: &str) -> Option<(u32, u32)> {
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
Some((r.height(), r.width()))
}
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+1
View File
@@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
}
}
"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()));
+1
View File
@@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
series,
legend: LegendPos::Bottom,
data_labels: false,
data_table: None,
gridlines: true,
style: 1,
x_title: None,
+82
View File
@@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
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<Json> {
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<Json> {
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<Json> {
+136 -9
View File
@@ -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<CommandSpec> {
"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<RangeRef> {
}
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
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<Json> {
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<Json> {
})
}
/// 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<bool>) -> 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<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
@@ -1751,6 +1798,86 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
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<Vec<String>> = (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::<Chart>(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();
+1
View File
@@ -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,
+1 -1
View File
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"freeze": sh.freeze,
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
"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::<Vec<_>>()})).collect::<Vec<_>>(),
"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::<Vec<_>>()})).collect::<Vec<_>>(),
"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::<Vec<_>>()})).collect::<Vec<_>>(),
"images": sh.images.len(),
"shapes": sh.shapes.len(),
"conditionalFormats": sh.cond_formats.len(),
+1
View File
@@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
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,
+26 -12
View File
@@ -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<CommandSpec> {
"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<CommandSpec> {
"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<Json> {
"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<Json> {
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<Json> {
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();
+22 -11
View File
@@ -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)>;
+160
View File
@@ -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<String> {
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<String> {
let (pdf, pages) = pdf_of(s, json!({}));
let text = String::from_utf8_lossy(&pdf);
let streams: Vec<String> = 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<Vec<Json>> = (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();
+21 -3
View File
@@ -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);
+96
View File
@@ -317,6 +317,102 @@ fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() {
}
}
#[test]
fn a_python_spill_whose_blank_cells_have_no_records_is_still_a_spill() {
// A1's result spills over A1:A3, and the file holds no records for the blank A2 and A3.
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c></row></sheetData>";
let mut s = s();
open(&mut s, "sparse.xlsx", &excel_shaped(data, Some(&["[1, None, None]"])));
let sh = s.doc().unwrap().wb.active().unwrap();
let a1 = CellRef::new(0, 0);
assert_eq!(sh.spill_ranges.get(&a1), Some(&RangeRef::parse("A1:A3").unwrap()), "the range is recorded at its anchor");
assert_eq!(sh.spill_anchor(CellRef::parse("A3").unwrap()), Some(a1));
let kept = sh.cell(a1).and_then(|c| c.py.as_ref()).and_then(|p| p.output.as_ref()).map(|o| o.value.clone());
assert_eq!(kept, Some(array(3, 1, vec![1.0.into(), Value::Empty, Value::Empty])), "the result is rebuilt over the whole range");
// A formula entered in the spill takes the area, as with any spill.
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
}
#[test]
fn a_python_spill_range_larger_than_a_result_keeps_its_values_as_plain_values() {
// C1:D600000 is 1,200,000 cells, more than a result may have but within what the reader
// tracks; its cached values are kept as the file shows them, and nothing spills.
let (mut s, _) = build();
let bytes = save(&mut s, "xlsx");
let crafted = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:D600000\""));
assert_ne!(crafted, bytes);
open(&mut s, "crafted.xlsx", &crafted);
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 2)), "{:?}", sh.spill_ranges);
assert_eq!(
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
);
let d2 = sh.cell(CellRef::parse("D2").unwrap()).expect("D2 is stored");
assert!(d2.py.is_none() && d2.formula.is_none(), "a plain value");
}
#[test]
fn python_spills_read_past_the_scan_budget_keep_their_values_as_plain_values() {
// A1's result spills over A1:A5000. Four thousand ordinary dynamic arrays on row 1 make each
// later plain cell's range lookup cost 4,001 checks, so the reader's scan budget runs out
// part way down column A, and the rest of the spill is read as plain cells.
let arrays: String = (1..=4_000u32)
.map(|c| {
let at = CellRef::new(0, c).a1();
let below = CellRef::new(1, c).a1();
format!("<c r=\"{at}\" cm=\"1\"><f t=\"array\" ref=\"{at}:{below}\">SEQUENCE(2)</f><v>1</v></c>")
})
.collect();
let rows: String = (2..=5_000u32).map(|r| format!("<row r=\"{r}\"><c r=\"A{r}\"><v>{r}</v></c></row>")).collect();
let data = format!(
"<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A5000\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>{arrays}</row>{rows}</sheetData>"
);
let mut s = s();
open(&mut s, "budget.xlsx", &excel_shaped(&data, Some(&["list(range(1, 5001))"])));
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "no live spill");
assert!(code(&s, "A1").is_some_and(|p| p.output.is_none()), "no result is rebuilt");
assert_eq!((v(&s, "A2"), v(&s, "A5000")), (Value::Number(2.0), Value::Number(5000.0)), "the values stay");
s.execute("formulas.calculateNow", json!({})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(1.0), "the cell keeps its value, not #SPILL!");
}
#[test]
fn deleting_the_data_python_code_reads_drops_its_kept_result() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "3"})).unwrap();
py(&mut s, "C5", "xl(\"A1:A2\") * 2");
cache(&mut s, "C5", array(2, 1, vec![2.0.into(), 4.0.into()]), "list");
py(&mut s, "E5", "xl(\"A1:A3\")");
cache(&mut s, "E5", Value::Number(3.0), "int");
s.execute("formulas.calculateNow", json!({})).unwrap();
assert_eq!(v(&s, "C6"), Value::Number(4.0));
// A move keeps the result: the reference still names the same data.
s.execute("home.insertRows", json!({"rows": "1"})).unwrap();
assert!(code(&s, "C6").is_some_and(|p| p.output.is_some() && !p.code.contains("A1:A2")), "{:?}", code(&s, "C6"));
assert_eq!(v(&s, "C7"), Value::Number(4.0));
assert!(code(&s, "E6").is_some_and(|p| p.output.is_some()));
// Deleting the rows C6 reads leaves `#REF!`, and deleting part of E6's range shrinks it: in
// both, the kept result no longer belongs to the code.
s.execute("home.deleteRows", json!({"rows": "2:3"})).unwrap();
let c4 = code(&s, "C4").expect("the Python cell moved up to C4");
assert!(c4.code.contains("#REF!") && c4.output.is_none(), "{c4:?}");
assert_eq!(v(&s, "C4"), Value::Error(CellError::Blocked));
assert_eq!(v(&s, "C5"), Value::Empty, "its spill is gone");
let e4 = code(&s, "E4").expect("the Python cell moved up to E4");
assert!(!e4.code.contains("#REF!") && e4.output.is_none(), "{e4:?}");
assert_eq!(v(&s, "E4"), Value::Error(CellError::Blocked));
// Rows inserted inside a range grow it: also new data.
py(&mut s, "G1", "xl(\"A1:A2\")");
cache(&mut s, "G1", Value::Number(2.0), "int");
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
assert!(code(&s, "G1").is_some_and(|p| p.output.is_none()), "{:?}", code(&s, "G1"));
}
#[test]
fn a_script_is_measured_after_its_escapes_are_decoded() {
let max = gridcraft_model::python::MAX_FORMULA_CHARS;
+1
View File
@@ -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;
+233
View File
@@ -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<String> {
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();
}
+43 -1
View File
@@ -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<Series>,
pub legend: LegendPos,
pub data_labels: bool,
#[serde(default)]
pub data_table: Option<ChartDataTable>,
pub gridlines: bool,
pub style: u32,
pub x_title: Option<String>,
@@ -562,6 +580,30 @@ pub struct Chart {
pub pivot: Option<String>,
}
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,
+67
View File
@@ -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<PageOrder> {
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());
}
}
+12
View File
@@ -46,8 +46,20 @@ impl SheetApp {
if let Some(r) = self.run_ui_command(id, &params) {
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, &params)
{
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;
+13 -2
View File
@@ -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));
+16 -5
View File
@@ -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(
+115 -3
View File
@@ -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<Field> {
fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
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<Field> {
],
"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<Field> {
"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<Field> {
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<String, Json> {
"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<String, Json> {
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();
+52 -3
View File
@@ -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));
}
+62 -11
View File
@@ -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<TraceArrow>,
}
/// 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<Trace> {
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<Trace> {
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;
}
}
}
+172 -26
View File
@@ -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());
}
}
+1
View File
@@ -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",
"Python Mode" => "Režim Pythonu",
"Evaluate Formula" => "Vyhodnotit vzorec",
+11
View File
@@ -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",
"Python Mode" => "Python-Modus",
"Evaluate Formula" => "Formel auswerten",
+4
View File
@@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Arrows" => "Κατάργηση βελών",
"Show Formulas" => "Εμφάνιση τύπων",
"Error Checking" => "Έλεγχος σφαλμάτων",
"Trace Error" => "Εντοπισμός σφάλματος",
"Circular References" => "Κυκλικές αναφορές",
"Python Mode" => "Λειτουργία Python",
"Evaluate Formula" => "Αξιολόγηση τύπου",
@@ -676,6 +677,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" => "Κόμμα",
+1
View File
@@ -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",
"Python Mode" => "Modo Python",
"Evaluate Formula" => "Evaluar fórmula",
+1
View File
@@ -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",
"Python Mode" => "Mode Python",
"Evaluate Formula" => "Évaluation de formule",
+4
View File
@@ -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",
"Python Mode" => "Modalità Python",
"Evaluate Formula" => "Valuta formula",
@@ -557,6 +558,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",
+4
View File
@@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Remove Arrows", "矢印の解除"),
("Show Formulas", "数式の表示"),
("Error Checking", "エラーチェック"),
("Trace Error", "エラーのトレース"),
("Circular References", "循環参照"),
("Python Mode", "Python モード"),
("Evaluate Formula", "数式の検証"),
@@ -579,6 +580,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", "コンマ"),
+1
View File
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Arrows" => "화살표 제거",
"Show Formulas" => "수식 표시",
"Error Checking" => "오류 검사",
"Trace Error" => "오류 추적",
"Circular References" => "순환 참조",
"Python Mode" => "Python 모드",
"Evaluate Formula" => "수식 계산",
+1
View File
@@ -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",
"Python Mode" => "Python-modus",
"Evaluate Formula" => "Formule evalueren",
+4
View File
@@ -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",
"Python Mode" => "Tryb Python",
"Evaluate Formula" => "Szacuj formułę",
@@ -551,6 +552,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",
+4
View File
@@ -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",
"Python Mode" => "Modo Python",
"Evaluate Formula" => "Avaliar Fórmula",
@@ -573,6 +574,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",
+4
View File
@@ -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",
"Python Mode" => "Mod Python",
"Evaluate Formula" => "Evaluare formulă",
@@ -557,6 +558,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ă",
+1
View File
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Arrows" => "Убрать стрелки",
"Show Formulas" => "Показать формулы",
"Error Checking" => "Проверка ошибок",
"Trace Error" => "Источник ошибки",
"Circular References" => "Циклические ссылки",
"Python Mode" => "Режим Python",
"Evaluate Formula" => "Вычислить формулу",
+1
View File
@@ -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",
"Python Mode" => "Python režim",
"Evaluate Formula" => "Proceni formulu",
+1
View File
@@ -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",
"Python Mode" => "Python Modu",
"Evaluate Formula" => "Formülü Değerlendir",
+4
View File
@@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Arrows" => "Прибрати стрілки",
"Show Formulas" => "Показати формули",
"Error Checking" => "Перевірка помилок",
"Trace Error" => "Джерело помилки",
"Circular References" => "Циклічні посилання",
"Python Mode" => "Режим Python",
"Evaluate Formula" => "Обчислити формулу",
@@ -555,6 +556,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",
+1
View File
@@ -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",
"Python Mode" => "Chế độ Python",
"Evaluate Formula" => "Đánh giá công thức",
+1
View File
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Arrows" => "删除箭头",
"Show Formulas" => "显示公式",
"Error Checking" => "错误检查",
"Trace Error" => "追踪错误",
"Circular References" => "循环引用",
"Python Mode" => "Python 模式",
"Evaluate Formula" => "公式求值",
+124 -6
View File
@@ -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<String>,
/// 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<String>,
@@ -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<PendingClose>,
/// 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<Dialog> {
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<Dialog> {
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<Result<Json, String>> {
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 => {}
}
+9 -3
View File
@@ -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;
}
+22
View File
@@ -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() {
+3
View File
@@ -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})),
],
+8
View File
@@ -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| {
+2
View File
@@ -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);
+12
View File
@@ -121,6 +121,10 @@ pub struct Tokens {
/// both themes.
pub sheet_py_tag: Color32,
pub on_sheet_py_tag: 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`].
@@ -153,6 +157,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)]
@@ -257,6 +264,8 @@ impl Tokens {
on_badge: pick(dark, Color32::WHITE, rgb(0x0B1F17)),
sheet_py_tag: SHEET_PY_TAG,
on_sheet_py_tag: Color32::WHITE,
sheet_trace_arrow: SHEET_TRACE_ARROW,
sheet_trace_error: SHEET_TRACE_ERROR,
}
}
@@ -408,6 +417,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);
@@ -457,6 +468,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");
}
+13
View File
@@ -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<u64> {
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<u64> {
self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min()
+138
View File
@@ -18,6 +18,8 @@ thread_local! {
static INFOS: RefCell<egui::ViewportIdMap<ViewportInfo>> = RefCell::new(egui::ViewportIdMap::default());
/// The viewport commands the app sent this frame.
static SENT: RefCell<Vec<(ViewportId, ViewportCommand)>> = const { RefCell::new(Vec::new()) };
/// Input events for the root window's next frame (a key pressed in it).
static EVENTS: RefCell<Vec<egui::Event>> = 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]);
}
@@ -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);
}
+125 -2
View File
@@ -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)]);
}
+111
View File
@@ -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();
+492 -6
View File
@@ -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<egui::ViewportCommand> {
@@ -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<egui::ViewportCommand> {
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));
}
+2
View File
@@ -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;
@@ -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]);
}
@@ -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<String> {
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, &params) {
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}");
}
}
}
+23
View File
@@ -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");
}
+16
View File
@@ -268,6 +268,12 @@ pub fn read_chart(root: &El) -> Option<Chart> {
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,
"<c:dTable><c:showHorzBorder val=\"{}\"/><c:showVertBorder val=\"{}\"/><c:showOutline val=\"{}\"/><c:showKeys val=\"{}\"/></c:dTable>",
u8::from(t.horizontal_border),
u8::from(t.vertical_border),
u8::from(t.outline),
u8::from(t.show_keys)
);
}
s.push_str("</c:plotArea>");
let pos = match ch.legend {
LegendPos::None => None,
+1
View File
@@ -130,6 +130,7 @@ pub fn read_chartex(root: &El) -> Option<Chart> {
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"),
+43 -6
View File
@@ -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;
@@ -93,6 +93,33 @@ fn forget_dynamic(st: &mut RowState, pos: CellRef) {
}
}
/// Records the spill range of the Python cell read at `pos`, its anchor, when the file declares
/// one that a result can fill: the spill then exists even when the file holds no records for its
/// blank cells. Charged like [`dynamic_range`]'s scans.
fn record_python_spill(st: &mut RowState, sheet: &mut Sheet, pos: CellRef) {
if st.dynamic_checks > MAX_DYNAMIC_CHECKS {
return;
}
st.dynamic_checks = st.dynamic_checks.saturating_add(st.dynamic.len() as u64);
let Some(r) = st.dynamic.iter().rev().find(|r| r.start == pos).copied() else { return };
if r.count() > 1 && r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64 {
sheet.spill_ranges.insert(pos, r);
}
}
/// Past the scan budget, the cells of a Python spill read afterwards were kept as plain cells, not
/// as its spilled values, and a result rebuilt over them would block itself. Every Python spill of
/// such a sheet keeps its values as plain values instead, with no live spill.
fn settle_python_spills_past_budget(st: &RowState, sheet: &mut Sheet) {
if st.dynamic_checks <= MAX_DYNAMIC_CHECKS {
return;
}
let anchors: Vec<CellRef> = sheet.spill_ranges.keys().copied().filter(|a| sheet.cells.get(*a).is_some_and(|c| c.py.is_some())).collect();
for anchor in anchors {
sheet.settle_spill(anchor);
}
}
const MAX_HYPERLINK_CELLS: u64 = 10_000;
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
@@ -149,6 +176,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
if st.dropped > 0 {
cx.warn(format!("sheet \"{name}\": {} cells or rows outside the 1,048,576 × 16,384 grid were dropped", st.dropped));
}
settle_python_spills_past_budget(&st, &mut sheet);
drop(bytes);
let mut fit_to_page = false;
@@ -206,6 +234,8 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
if e.attr("orientation") == Some("landscape") {
sheet.print.orientation = Orientation::Landscape;
}
// `downThenOver` when absent (ECMA-376's default); an unknown value reads as that too.
sheet.print.page_order = e.attr("pageOrder").and_then(PageOrder::parse).unwrap_or_default();
if let Some(p) = e.attr_u32("paperSize").and_then(paper_name) {
sheet.print.paper = p.to_string();
}
@@ -555,18 +585,25 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
(None, None)
}
};
if py.is_some() {
record_python_spill(st, sheet, pos);
}
let spilled_from = if formula.is_none() && py.is_none() { dynamic_range(st, pos) } else { None };
let python_anchor = spilled_from.is_some_and(|r| sheet.cells.get(r.start).is_some_and(|a| a.py.is_some()));
let value = match spilled_from {
// A Python cell's spilled values are kept: they are its result until it runs again. A
// range larger than a result may be is a crafted file's: its values are dropped, like a
// formula's spilled values, and no spill is recorded.
Some(r) if r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64 && sheet.cells.get(r.start).is_some_and(|a| a.py.is_some()) => {
// A Python cell's spilled values are kept: they are its result until it runs again.
Some(r) if python_anchor && r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64 => {
if !value.is_empty() {
sheet.spill.insert(pos, value);
}
sheet.spill_ranges.insert(r.start, r);
Value::Empty
}
// A Python cell's range larger than a result may be is a crafted file's: it is no spill,
// and its cells keep the values the file shows as plain values, as when the cell keeps
// only its own value.
Some(_) if python_anchor => value,
// A formula's spilled values are dropped: recalculation fills them again.
Some(_) => Value::Empty,
None => value,
};
+6 -2
View File
@@ -5,8 +5,8 @@ use std::fmt::Write as _;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, Sheet,
SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, PageOrder,
Sheet, SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
};
use crate::drawing::{Obj, image_content_type, image_ext};
@@ -385,6 +385,10 @@ fn write_sheet_printing<'a>(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}\"");
+2
View File
@@ -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;
@@ -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,
+46
View File
@@ -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!(
"<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\"><c:chart><c:plotArea>\
<c:barChart><c:barDir val=\"col\"/><c:grouping val=\"clustered\"/><c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>\
<c:val><c:numLit><c:formatCode>General</c:formatCode><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>12</c:v></c:pt></c:numLit></c:val>\
</c:ser><c:axId val=\"1\"/><c:axId val=\"2\"/></c:barChart><c:catAx/><c:valAx/>{table}</c:plotArea></c:chart></c:chartSpace>"
);
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("<c:dTable/>").data_table,
Some(ChartDataTable { show_keys: false, horizontal_border: false, vertical_border: false, outline: false })
);
assert_eq!(
chart("<c:dTable><c:showKeys/><c:showHorzBorder/><c:showVertBorder/><c:showOutline/></c:dTable>").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));
}
}
+1
View File
@@ -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,
@@ -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,
@@ -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,
+63
View File
@@ -0,0 +1,63 @@
//! Page Setup › Sheet › Page order: `<pageSetup pageOrder>` 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 `<pageSetup …/>` element of a sheet's XML.
fn page_setup(xml: &str) -> &str {
let start = xml.find("<pageSetup").unwrap_or_else(|| panic!("no pageSetup: {xml}"));
let len = xml[start..].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#"<sheetData/><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><pageSetup paperSize="9" pageOrder="overThenDown" orientation="portrait"/>"#;
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#"<pageSetup paperSize="9" pageOrder="overThenDown" orientation="portrait"/>"#);
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#"<sheetData/><pageSetup paperSize="9" pageOrder="downThenOver" orientation="portrait"/>"#,
r#"<sheetData/><pageSetup paperSize="9" orientation="portrait"/>"#,
r#"<sheetData/><pageSetup pageOrder="sideways"/>"#,
"<sheetData/>",
] {
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("<pageSetup").count() <= 1, "{xml}");
}
// A new workbook, and a sheet set back to Down, then over.
let mut wb = Workbook::new();
assert!(!sheet_xml(&write_xlsx(&wb).unwrap()).contains("pageOrder"));
wb.sheet_mut(0).unwrap().print.page_order = PageOrder::OverThenDown;
assert!(sheet_xml(&write_xlsx(&wb).unwrap()).contains(r#"pageOrder="overThenDown""#));
wb.sheet_mut(0).unwrap().print.page_order = PageOrder::DownThenOver;
assert!(!sheet_xml(&write_xlsx(&wb).unwrap()).contains("pageOrder"));
}
+2
View File
@@ -301,6 +301,7 @@ fn sample() -> 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()),
@@ -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,
@@ -53,6 +53,7 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart {
}],
legend: LegendPos::Bottom,
data_labels: false,
data_table: None,
gridlines: true,
style: 2,
x_title: None,
+17 -2
View File
@@ -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) |
+5 -3
View File
@@ -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: `<pageSetup pageOrder="overThenDown">` 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) |
+8 -6
View File
@@ -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` |
+16 -4
View File
@@ -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
+9 -6
View File
@@ -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) |
+8 -4
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells: Python mode in the formula bar; 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells: Python mode in the formula bar; earlier: 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
@@ -26,7 +26,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| 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 |
| Python cells: Python mode | yes | yes (11-01): Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on a Mac), `=PY` then Tab, or editing a Python cell edits the code in the formula bar's code editor, with a PY badge beside fx; Enter starts a new line with the indentation, Tab and Shift+Tab indent, Ctrl+Enter (Cmd+Enter) puts the code in the cell through `python.set`, Esc cancels. Deleting all the code and committing clears a Python cell, as an empty entry clears any other cell. The formula bar shows while a Python edit is open, also when View › Formula Bar hides it. On a protected sheet, a Python cell formatted Hidden shows no code in the formula bar and starts no edit, as a hidden formula shows none | this build stores Python code and doesn't run it: new or changed code shows `#BLOCKED!`; no `xl()` pointing on the sheet yet |
| 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 |
@@ -161,7 +161,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,
@@ -198,7 +198,11 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused |
| 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused |
| 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 |