Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away

> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (What-If data tables: Data Table dialog, faster recalculation, checked in Excel) · **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.
Expand Down Expand Up @@ -132,6 +132,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-14 | Data › What-If Analysis › Data Table… opens Excel's dialog (Row input cell, Column input cell; German labels) and makes a live `{=TABLE()}` over the selection; the What-If menu follows Excel's order (Scenario Manager, Goal Seek, Data Table). Tables recalculate on one working copy each (over 10× faster after an edit on large tables), an edited input value recalculates only its own row or column, and a work budget keeps a hostile file from hanging the app. XLSX data tables written by GridCraft open live in Excel 365, and Excel's own form is read (builds on #548 by prxnvt) |
| 2026-10-12 | What-If data tables are live: `{=TABLE()}` results recalculate when their inputs or formulas change (on F9 in Automatic Except for Data Tables mode), edits of part of a table are refused, and tables round-trip through XLSX |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
Expand Down Expand Up @@ -193,6 +195,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log: Data Table dialog, data table recalculation speed, Excel round trip |
| 2026-10-12 | minor | Live What-If data tables; progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
Expand Down
2 changes: 1 addition & 1 deletion crates/calc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub mod recalc;
mod trimrange;

pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
pub use recalc::{Calc, Key, evaluate, now_serial};
pub use recalc::{Calc, Key, Trial, evaluate, now_serial};

#[cfg(test)]
mod tests;
312 changes: 301 additions & 11 deletions crates/calc/src/recalc.rs

Large diffs are not rendered by default.

296 changes: 296 additions & 0 deletions crates/calc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,158 @@ fn trim_references() {
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
}

fn table(range: &str, row_input: Option<&str>, col_input: Option<&str>) -> gridcraft_model::DataTable {
gridcraft_model::DataTable { range: gridcraft_core::RangeRef::parse(range).unwrap(), row_input: row_input.map(c), col_input: col_input.map(c) }
}

#[test]
fn data_tables_recalculate_with_their_inputs_and_formulas() {
let mut t = T::new();
// Rate B1, amount B2; a one-variable table with amounts down D3:D5 and two formulas.
t.set("B1", "0.1");
t.set("B2", "1000");
for (cell, v) in [("D3", "100"), ("D4", "200"), ("D5", "300")] {
t.set(cell, v);
}
t.set("E2", "=B2*(1+B1)");
t.set("F2", "=B2*2");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("D2:F5", None, Some("B2")));
t.set("H1", "=SUM(E3:E5)");
t.calc.recalc_all(&mut t.wb);
assert!((t.num("E3") - 110.0).abs() < 1e-9);
assert_eq!(t.num("F5"), 600.0);
assert!((t.num("H1") - 660.0).abs() < 1e-9, "formulas over the results recalculate");
assert_eq!(t.num("B2"), 1000.0, "the input cell keeps its value");
// A precedent of the formulas changes: the results and their dependents follow.
t.set("B1", "0.5");
assert_eq!(t.num("E4"), 300.0);
assert_eq!(t.num("H1"), 900.0);
// So does an input value on the edge.
t.set("D3", "1000");
assert_eq!(t.num("E3"), 1500.0);
// And a formula on the edge.
t.set("F2", "=B2*3");
assert_eq!(t.num("F4"), 600.0);
// Two-variable: rates across the top (row input B1), amounts down the side (column input B2).
t.set("J1", "=B2*B1");
for (cell, v) in [("K1", "1"), ("L1", "2"), ("J2", "10"), ("J3", "20")] {
t.set(cell, v);
}
t.wb.sheet_mut(0).unwrap().data_tables.push(table("J1:L3", Some("B1"), Some("B2")));
t.calc.recalc_all(&mut t.wb);
assert_eq!((t.num("K2"), t.num("L2"), t.num("K3"), t.num("L3")), (10.0, 20.0, 20.0, 40.0));
t.set("J1", "=B2*B1+1");
assert_eq!(t.num("L3"), 41.0);
t.set("L1", "3");
assert_eq!(t.num("L3"), 61.0);
// Row-oriented one-variable table: rates across the top, a formula down the left column.
t.set("N2", "=B2*B1");
for (cell, v) in [("O1", "1"), ("P1", "3")] {
t.set(cell, v);
}
t.wb.sheet_mut(0).unwrap().data_tables.push(table("N1:P2", Some("B1"), None));
t.calc.recalc_all(&mut t.wb);
assert_eq!((t.num("O2"), t.num("P2")), (1000.0, 3000.0));
}

#[test]
fn data_tables_wait_for_f9_in_except_tables_and_manual_modes() {
for mode in [gridcraft_model::CalcMode::AutomaticExceptTables, gridcraft_model::CalcMode::Manual] {
let mut t = T::new();
t.set("B1", "2");
t.set("E1", "=B1*10");
t.set("D2", "1");
t.set("D3", "2");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("D1:E3", None, Some("B1")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("E3"), 20.0);
t.wb.calc.mode = mode;
t.set("E1", "=B1*100");
assert_eq!(t.num("E3"), 20.0, "{mode:?}");
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("E3"), 200.0, "{mode:?}");
}
}

#[test]
fn data_tables_that_read_their_own_results_are_circular() {
let mut t = T::new();
// A formula on the edge reads the table's own results: a circular reference.
t.set("B1", "1");
t.set("E1", "=B1+SUM(E2:E3)");
t.set("D2", "1");
t.set("D3", "2");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("D1:E3", None, Some("B1")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("E2"), Value::Error(CellError::Circ));
t.set("B1", "5");
assert_eq!(t.get("E3"), Value::Error(CellError::Circ));
// So do two tables that read each other's results.
t.set("E1", "=B1+SUM(H2:H3)");
t.set("H1", "=B2+SUM(E2:E3)");
t.set("G2", "1");
t.set("G3", "2");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("G1:H3", None, Some("B2")));
t.calc.recalc_all(&mut t.wb);
assert_eq!((t.get("E2"), t.get("H3")), (Value::Error(CellError::Circ), Value::Error(CellError::Circ)));
// Breaking the loop calculates both again.
t.set("H1", "=B2*2");
assert_eq!((t.num("H3"), t.num("E2")), (4.0, 7.0));
}

#[test]
fn data_tables_that_read_tables_follow_them() {
let mut t = T::new();
t.set("B1", "5");
// A second table whose formula reads the first table's results follows it.
t.set("D2", "1");
t.set("D3", "2");
t.set("G1", "=E2*B1");
t.set("H1", "=SUM(E2:E3)");
t.set("F2", "1");
t.set("F3", "2");
t.set("E1", "=B1*10");
// Listed first, so it waits for the second pass.
t.wb.sheet_mut(0).unwrap().data_tables.push(table("F1:H3", None, Some("B2")));
t.wb.sheet_mut(0).unwrap().data_tables.push(table("D1:E3", None, Some("B1")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("H2"), 30.0);
t.set("E1", "=B1*100");
assert_eq!((t.num("E3"), t.num("H3")), (200.0, 300.0));
}

#[test]
fn hostile_data_tables_are_not_calculated() {
let mut t = T::new();
t.set("A1", "1");
t.set("B1", "=A1");
let sh = t.wb.sheet_mut(0).unwrap();
// Too many result cells, no input cell, an input cell inside the table, a one-cell range.
sh.data_tables.push(table("A10:XFD1048576", None, Some("A1")));
sh.data_tables.push(table("A2:B3", None, None));
sh.data_tables.push(table("C2:D3", None, Some("D3")));
sh.data_tables.push(table("F2:F2", None, Some("A1")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("B20"), Value::Empty);
assert_eq!(t.get("D3"), Value::Empty);
t.set("A1", "2");
assert_eq!(t.num("B1"), 2.0);
}

#[test]
fn data_tables_over_indirect_references_are_calculated() {
let mut t = T::new();
t.set("B1", "2");
t.set("E1", "=INDIRECT(\"B1\")*10");
t.set("D2", "1");
t.set("D3", "3");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("D1:E3", None, Some("B1")));
t.calc.recalc_all(&mut t.wb);
assert_eq!((t.num("E2"), t.num("E3")), (10.0, 30.0));
t.set("D3", "4");
assert_eq!(t.num("E3"), 40.0);
}

#[test]
fn cell_width_is_the_column_width_dialog_value_rounded() {
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
Expand Down Expand Up @@ -1266,3 +1418,147 @@ fn offset_extreme_arguments_return_ref() {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}

/// Random edits around data tables (input values along the edges, the formulas, their precedents
/// and the input cells): after each, the incrementally recalculated sheet, where an edited input
/// value recalculates only its own row, column or cells, equals a full recalculation.
#[test]
fn data_tables_recalculated_in_part_agree_with_a_full_recalculation() {
let mut t = T::new();
t.set("A1", "3");
t.set("B1", "0.1");
t.set("B2", "100");
t.set("A2", "=B1*A1");
// One-variable down a column (two formulas), across a row, and two-variable.
t.set("E2", "=B2*(1+B1)+A2");
t.set("F2", "=B2-A1");
t.set("H2", "=B1*B2");
t.set("K1", "=B1*B2+A1");
for (i, row) in (3..=6).enumerate() {
t.set(&format!("D{row}"), &(10 * (i + 1)).to_string());
t.set(&format!("K{}", row - 1), &(i + 1).to_string());
}
for (i, col) in ["I", "J", "K"].iter().enumerate() {
t.set(&format!("{col}1"), &format!("0.{}", i + 1));
}
for (i, col) in ["L", "M"].iter().enumerate() {
t.set(&format!("{col}1"), &(i + 2).to_string());
}
let sh = t.wb.sheet_mut(0).unwrap();
sh.data_tables.push(table("D2:F6", None, Some("B2")));
sh.data_tables.push(table("H1:J2", Some("B1"), None));
sh.data_tables.push(table("K1:M5", Some("B1"), Some("B2")));
t.set("O1", "=SUM(E3:F6)+SUM(L2:M5)+I2");
t.calc.recalc_all(&mut t.wb);
let cells = ["D3", "D4", "D5", "D6", "I1", "J1", "K2", "K3", "L1", "M1", "A1", "B1", "B2", "E2", "F2", "H2", "K1"];
let inputs = ["7", "0.25", "-3", "=A1*2", "=B2/10", "", "1e3", "=E3"];
let formulas = ["=B2*(1+B1)", "=B1+B2+A1", "=A2*B2", "=B1"];
let mut seed: u64 = 0x5EED;
let mut next = |n: usize| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((seed >> 33) as usize) % n
};
for step in 0..80 {
let at = cells[next(cells.len())];
let edge_formula = matches!(at, "E2" | "F2" | "H2" | "K1");
let input = if edge_formula { formulas[next(formulas.len())] } else { inputs[next(inputs.len())] };
t.set(at, input);
let mut full = t.wb.clone();
Calc::new().recalc_all(&mut full);
for (c, cell) in full.sheet(0).unwrap().cells.iter() {
let got = t.wb.sheet(0).unwrap().value(c);
let same = match (&got, &cell.value) {
(Value::Number(a), Value::Number(b)) => (a - b).abs() <= 1e-9 * a.abs().max(1.0),
(a, b) => a == b,
};
assert!(same, "step {step}: {at} := {input:?}; {} is {got:?}, a full recalculation gives {:?}", c.a1(), cell.value);
}
}
}

/// A table whose calculation would take more formula evaluations than a refresh may spend keeps
/// its values (a hostile file can't hang the application); a table within it is calculated.
#[test]
fn data_tables_past_the_work_budget_keep_their_values() {
let mut t = T::new();
// 400 formulas depend on B1: a 300 × 300 table would evaluate them 90,000 times.
let sh = t.wb.sheet_mut(0).unwrap();
sh.cells.set(c("B1"), Cell::value(Value::Number(1.0)));
sh.cells.set(c("ZZ1"), Cell::formula(Formula::new("=B1+1")));
for row in 2..=400 {
sh.cells.set(CellRef::new(row - 1, 701), Cell::formula(Formula::new(&format!("=ZZ{}+B1", row - 1))));
}
sh.cells.set(c("A1"), Cell::formula(Formula::new("=ZZ400")));
sh.data_tables.push(table("A1:KO301", Some("B1"), Some("B2")));
// A small one-variable table on the same sheet.
sh.cells.set(c("QA1"), Cell::formula(Formula::new("=B3*2")));
sh.cells.set(c("PZ2"), Cell::value(Value::Number(4.0)));
sh.cells.set(c("QA2"), Cell::value(Value::text("stale")));
sh.data_tables.push(table("PZ1:QA2", None, Some("B3")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("B2"), Value::Empty);
assert_eq!(t.get("KO301"), Value::Empty, "too much work: not calculated");
assert_eq!(t.num("QA2"), 8.0, "a table within the budget is calculated");
}

/// The substitutions of a table leave the calculation as they found it: circular references
/// found before are still listed, an input cell holding a formula keeps it (and its place in the
/// dependency graph), and formulas over it recalculate as before.
#[test]
fn data_tables_leave_the_calculation_as_they_found_it() {
let mut t = T::new();
t.set("Z1", "=Z1+1");
assert_eq!(t.calc.circular, vec![(0, c("Z1"))]);
t.set("A1", "5");
t.set("B2", "=A1*2");
t.set("E2", "=B2+1");
t.set("D3", "100");
t.set("D4", "200");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("D2:E4", None, Some("B2")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.calc.circular, vec![(0, c("Z1"))]);
assert_eq!((t.num("E3"), t.num("E4"), t.num("B2"), t.num("E2")), (101.0, 201.0, 10.0, 11.0));
assert!(t.wb.sheet(0).unwrap().cell(c("B2")).unwrap().formula.is_some(), "the input cell keeps its formula");
t.set("A1", "6");
assert_eq!((t.num("B2"), t.num("E2"), t.num("E4")), (12.0, 13.0, 201.0));
// With constant input cells, the table runs on this calculation's own graph.
t.set("B3", "1");
t.set("H2", "=B3*10");
t.set("G3", "2");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("G2:H3", None, Some("B3")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.calc.circular, vec![(0, c("Z1"))]);
assert_eq!(t.num("H3"), 20.0);
t.set("B3", "4");
assert_eq!((t.num("H2"), t.num("H3")), (40.0, 20.0));
}

/// A data table's results are formula cells in Excel, so a dynamic array can't spill over them,
/// even while they're blank; it can spill along the table's edges, and the table follows the
/// spilled input values (both found by the command-sequence fuzz).
#[test]
fn dynamic_arrays_and_data_tables() {
let full = |t: &T| {
let mut wb = t.wb.clone();
Calc::new().recalc_all(&mut wb);
wb
};
let mut t = T::new();
t.set("B1", "2");
t.wb.sheet_mut(0).unwrap().data_tables.push(table("K20:M22", None, Some("B1")));
t.calc.recalc_all(&mut t.wb);
t.set("K19", "=SEQUENCE(5,2)");
assert_eq!(t.get("K19"), Value::Error(CellError::Spill));
assert_eq!(full(&t).sheet(0).unwrap().value(c("K19")), Value::Error(CellError::Spill));
// Down the left edge: the spilled values are the table's input values.
t.set("L20", "=B1*10");
t.set("K19", "=SEQUENCE(4)");
assert_eq!((t.num("K22"), t.num("L21"), t.num("L22")), (4.0, 30.0, 40.0));
t.set("K19", "=SEQUENCE(4,1,5)");
assert_eq!((t.num("L21"), t.num("L22")), (70.0, 80.0));
let fresh = full(&t);
assert_eq!((fresh.sheet(0).unwrap().value(c("L21")), fresh.sheet(0).unwrap().value(c("L22"))), (Value::Number(70.0), Value::Number(80.0)));
// The spill shrinks off the edge: the input values are blank again.
t.set("K19", "=SEQUENCE(2)");
assert_eq!((t.num("L21"), t.num("L22")), (0.0, 0.0));
}
20 changes: 20 additions & 0 deletions crates/engine/src/cmd/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
let expr = wb.name_call_case(expr);
check_arg_counts(&expr)?;
check_no_table(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
// Formulas whose result is a date/time get a format from functions like TODAY().
Expand Down Expand Up @@ -213,6 +214,19 @@ fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
}
}

/// TABLE() is only made by What-If Analysis › Data Table; Excel refuses it typed into a cell.
fn check_no_table(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut table = false;
expr.walk(&mut |e| {
if let gridcraft_formula::Expr::Call(name, _) = e
&& name.eq_ignore_ascii_case("TABLE")
{
table = true;
}
});
if table { Err(EngineError::Other("That function isn't valid.".into())) } else { Ok(()) }
}

fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
Expand Down Expand Up @@ -1216,6 +1230,12 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
n.formula = gridcraft_formula::print(&new);
}
}
// Data tables ({=TABLE()}) are formulas of the target sheet too.
if let Some(i) = wb.sheet_index(target).filter(|&i| wb.sheet(i).is_some_and(|s| !s.data_tables.is_empty()))
&& let Some(sh) = wb.sheet_mut(i)
{
super::whatif::adjust_data_tables(sh, e);
}
}

// ---------------------------------------------------------------- clear
Expand Down
8 changes: 5 additions & 3 deletions crates/engine/src/cmd/formulas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,10 +578,12 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
cx.wb.calc.set_max_change(v);
}
let c = &cx.wb.calc;
// Switching to automatic, or changing how circular references are calculated outside
// manual mode, recalculates the workbook.
// Switching to automatic (or from manual to automatic except for data tables), or
// changing how circular references are calculated outside manual mode, recalculates the
// workbook.
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
let leaves_manual = before.mode == CalcMode::Manual && c.mode == CalcMode::AutomaticExceptTables;
if mode == Some(CalcMode::Automatic) || leaves_manual || (iteration_changed && c.mode != CalcMode::Manual) {
cx.structural = true;
}
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
Expand Down
Loading