Skip to content
Closed
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
4 changes: 3 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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 @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-25 | 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 Excel's `_xlfn._xlws.PY("…",0 or 1)` with cached values and spills, so Python cells from Excel open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
Expand Down Expand Up @@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Progress log: Python cells as data |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
Expand Down
80 changes: 70 additions & 10 deletions crates/calc/src/recalc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,10 @@ struct PassHost<'a> {
stack_top: usize,
/// Whether an evaluation ran past the stack budget since the innermost formula started.
out_of_stack: bool,
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
python: HashMap<Key, Value>,
}

impl<'a> PassHost<'a> {
Expand Down Expand Up @@ -468,8 +472,17 @@ impl<'a> PassHost<'a> {
taint: false,
stack_top: 0,
out_of_stack: false,
python: HashMap::default(),
}
}

/// Adds the dirty Python cells of the pass and their kept results: like the formulas, they
/// are pending until laid out, so every read path calculates them before reading them.
fn with_python(mut self, python: HashMap<Key, Value>) -> Self {
self.pending.extend(python.keys().copied());
self.python = python;
self
}
}

/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
Expand All @@ -483,7 +496,10 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
let own = sh.spill_ranges.get(&anchor);
let spilled_over = |c: CellRef| {
!own.is_some_and(|o| o.contains(c))
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
&& sh.spill_ranges.iter().any(|(o, r)| {
// A formula or Python cell recalculated in this pass lays its array out again.
*o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o))
})
};
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
Expand Down Expand Up @@ -560,6 +576,9 @@ impl PassHost<'_> {
}
return v;
}
if let Some(result) = self.python.get(&k).cloned() {
return self.python_result(k, result);
}
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
Expand Down Expand Up @@ -677,6 +696,36 @@ impl PassHost<'_> {
v
}

/// A Python cell's kept result as its value: an array spills from the cell unless it is
/// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a
/// crafted file holds one), which shows `#SPILL!`.
fn python_result(&mut self, k: Key, result: Value) -> Value {
self.pending.remove(&k);
let v = match result {
Value::Array(a) => {
let sane = a.rows >= 1
&& a.cols >= 1
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
if sane {
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
} else {
Value::Error(CellError::Spill)
}
}
v => v,
};
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
Value::Error(CellError::Spill)
}
_ => v,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
self.results.insert(k, v.clone());
v
}

/// A formula's previous value for iterative calculation: from the last iteration, else the
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
fn previous(&self, k: Key) -> Value {
Expand Down Expand Up @@ -817,12 +866,12 @@ impl Host for PassHost<'_> {
let (h, w) = (range.height() as usize, range.width() as usize);
let mut out = vec![Value::Empty; h * w];
let Some(sh) = self.wb.sheet(sheet) else { return out };
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
// `compute`, constants are copied straight from the store.
// Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go
// through `compute`, constants are copied straight from the store.
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
for (c, cell) in sh.cells.iter_range(range) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if cell.formula.is_some() {
if cell.formula.is_some() || cell.py.is_some() {
formulas.push((i, c));
} else if let Some(slot) = out.get_mut(i) {
*slot = cell.value.clone();
Expand Down Expand Up @@ -888,11 +937,11 @@ impl Host for PassHost<'_> {
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
let wb = self.wb;
let Some(sh) = wb.sheet(sheet) else { return false };
// Formula cells first, in row-major order like `range_values`: reading one calculates it
// and the pass remembers the value, so the walk below gets the same value again without
// this method holding any.
// Formula and Python cells first, in row-major order like `range_values`: reading one
// calculates it (a Python cell lays out its result) and the pass remembers the value, so
// the walk below gets the same value again without this method holding any.
for (c, cell) in sh.cells.iter_range(range) {
if cell.formula.is_some() {
if cell.formula.is_some() || cell.py.is_some() {
let _ = self.cell_value(sheet, c);
}
}
Expand All @@ -902,7 +951,7 @@ impl Host for PassHost<'_> {
return false;
}
for (c, cell) in sh.cells.iter_range(range) {
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
if !go {
break;
}
Expand Down Expand Up @@ -953,6 +1002,10 @@ impl Calc {
if cell.value == Value::Error(CellError::Circ) {
circ.push((si, c));
}
} else if cell.py.is_some() {
// A Python cell lays out its kept result, so it is calculated like a formula
// that reads nothing: its spill is blocked and unblocked like a formula's.
self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false });
}
}
}
Expand Down Expand Up @@ -1074,11 +1127,14 @@ impl Calc {
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
for &k in changed {
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
let python = wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.py.is_some());
match formula.and_then(|f| f.expr()) {
Some(e) => {
let (areas, dynamic) = precedents(wb, k.0, &e);
self.graph.insert(k, Node { areas, dynamic });
}
// See `rebuild`: its spill is laid out again from its result when it is calculated.
None if python => self.graph.insert(k, Node { areas: Vec::new(), dynamic: false }),
None => {
self.graph.remove(k);
// A formula replaced by a constant (or cleared) takes its spilled values with it.
Expand Down Expand Up @@ -1206,11 +1262,15 @@ impl Calc {
exprs.insert(*k, e);
}
}
let python: HashMap<Key, Value> = dirty
.iter()
.filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v)))
.collect();
let now = self.now();
let mut rng = self.rng;
let tp = prof_now();
let (results, spills, cycles) = {
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python);
host.iterate = wb.calc.iterative;
for k in &dirty {
host.calculate(*k);
Expand Down
60 changes: 60 additions & 0 deletions crates/calc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2274,3 +2274,63 @@ fn loan_schedule_residue_keeps_excels_sign() {
}
assert_eq!(t.get("P7"), Value::Text("".into()));
}

/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
use gridcraft_model::python::{PyCode, PyOutput};
let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true };
let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") };
let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) };
t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
(0, c(at))
}

fn column(values: &[f64]) -> Value {
let data = values.iter().map(|v| Value::Number(*v)).collect();
Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap()))
}

#[test]
fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
// B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before
// it in the pass; each must lay it out first rather than read its old value, 1.
let build = |t: &mut T| {
t.set("B2", "10");
t.set("B3", "100");
t.set("A1", "=B1");
t.set("A2", "=MAX(B1:B3)");
t.set("A3", "=SUM(B1:B3)");
t.set("A4", "=COUNTA(B1:B3)");
t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))");
};
let check = |t: &T, how: &str| {
assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}");
assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference");
assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read");
assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell");
assert_eq!(t.num("A4"), 3.0, "{how}");
assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range");
};

// A full recalculation.
let mut t = T::new();
build(&mut t);
plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
t.calc.recalc_all(&mut t.wb);
check(&t, "full");

// An incremental one: B1 becomes a Python cell after its readers.
let mut t = T::new();
t.set("B1", "1");
build(&mut t);
assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0));
let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
t.calc.cells_changed(&mut t.wb, &[k]);
check(&t, "incremental");

// Unblocked, the readers see the new array.
t.set("B2", "");
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
}
27 changes: 25 additions & 2 deletions crates/core/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@ pub enum CellError {
Calc,
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
Circ,
/// A Python cell's code raised an error, or Python stopped.
Python,
/// A Python cell is waiting to run.
Busy,
/// A Python cell ran longer than the time limit.
Timeout,
/// Python can't run here: the workbook's content isn't enabled, or this build has no Python.
Blocked,
}

impl CellError {
pub const ALL: [CellError; 10] = [
pub const ALL: [CellError; 14] = [
CellError::Null,
CellError::Div0,
CellError::Value,
Expand All @@ -35,6 +43,10 @@ impl CellError {
CellError::GettingData,
CellError::Spill,
CellError::Calc,
CellError::Python,
CellError::Busy,
CellError::Timeout,
CellError::Blocked,
];
pub fn as_str(&self) -> &'static str {
match self {
Expand All @@ -49,9 +61,16 @@ impl CellError {
CellError::Spill => "#SPILL!",
CellError::Calc => "#CALC!",
CellError::Circ => "#CIRC!",
CellError::Python => "#PYTHON!",
CellError::Busy => "#BUSY!",
CellError::Timeout => "#TIMEOUT!",
CellError::Blocked => "#BLOCKED!",
}
}
/// `ERROR.TYPE` number.
/// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!)
/// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or
/// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use,
/// and may change if Excel's numbers become known.
pub fn code(&self) -> u32 {
match self {
CellError::Null => 1,
Expand All @@ -65,6 +84,10 @@ impl CellError {
CellError::Spill => 9,
CellError::Calc => 14,
CellError::Circ => 15,
CellError::Blocked => 11,
CellError::Python => 19,
CellError::Busy => 20,
CellError::Timeout => 21,
}
}
pub fn parse(s: &str) -> Option<CellError> {
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/cmd/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,13 +1124,13 @@ fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json>
let sh = cx.sheet_mut(si)?;
for c in &occupied {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style });
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
}
let mut touched = occupied.clone();
for ((r, c), v) in &g.cells {
let cell = CellRef::new(at.row + r, at.col + c);
let style = pct_styles.get(&cell).copied().unwrap_or_else(|| sh.style_id(cell));
sh.set_cell(cell, Cell { value: v.clone(), formula: None, style });
sh.set_cell(cell, Cell { value: v.clone(), formula: None, style, py: None });
touched.push(cell);
}
for c in touched {
Expand Down
Loading
Loading