Commit Graph

49 Commits

Author SHA1 Message Date
Brandon Thomas dcd469d12e Merge pull request #419 from neilcawse/fix/function-fixes
Functions: MODE/VALUE/SUMPRODUCT text args, ADDRESS [Book]Sheet, Excel-style lookup bisection, SUBTOTAL skips filtered rows

# Conflicts:
#	docs/file-format-parity.md
#	docs/function-parity.md
2026-10-11 18:05:41 -04:00
Brandon Thomas f170b89a92 Merge pull request #420 from neilcawse/fix/structref
Formulas: trim-reference operators (A1:.E10, A1.:E10, A1.:.E10); read unbracketed [[#This Row],Col]

# Conflicts:
#	docs/function-parity.md
#	docs/gaps.md
2026-10-11 18:05:40 -04:00
Brandon Thomas 80c2efe0f1 Merge pull request #421 from neilcawse/fix/iterative-calc
Calc: iterative calculation for circular references (Maximum Iterations, Maximum Change)

# Conflicts:
#	docs/function-parity.md
2026-10-11 18:05:39 -04:00
neilcawse 53690bace0 Functions: oracle fixes - MODE/VALUE/SUMPRODUCT text args, ADDRESS [Book]Sheet, Excel-style lookup bisection, SUBTOTAL skips filtered rows (table filter criteria kept)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse 9ce910f87f Formulas: trim-reference operators (A1:.E10, A1.:E10, A1.:.E10); read unbracketed [[#This Row],Col]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse fdb74c27dd Calc: iterative calculation for circular references (Maximum Iterations, Maximum Change)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
Brandon Thomas 6d59233e62 XLOOKUP reference path: an empty match_mode keeps the search_mode after it (review fix-up for #396)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:49:32 -04:00
Brandon Thomas c7bd7deebb Merge branch 'land' into fix-396 2026-10-11 15:49:00 -04:00
Brandon Thomas fca801c17e INDEX: compare the result size with saturating u64 math, so it can't overflow on 32-bit targets (review fix-up for #400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:43:11 -04:00
Brandon Thomas fbb2285532 Merge branch 'land' into fix-400 2026-10-11 15:42:32 -04:00
nihilist-narwhal 9d0053f3ad XLOOKUP: return a reference when the result range is one
XLOOKUP returned only values, so it couldn't end a range or feed
functions that need a reference: SUM(XLOOKUP("b",A1:A4,B1:B4):
XLOOKUP("d",A1:A4,B1:B4)) and SUM(B1:XLOOKUP(…)) gave #VALUE!, and so
did CELL("address",XLOOKUP(…)). A match on an empty cell gave 0, so
ISBLANK(XLOOKUP(…)) was FALSE and "x"&XLOOKUP(…) gave "x0" (#374). In
Excel XLOOKUP returns a reference to the matching row (or column) of
its result range. The evaluator now does so, finding the position with
XMATCH; arrays of lookup values, array arguments and errors still go
through the library function.
2026-10-11 13:59:08 -05:00
nihilist-narwhal 683ff6003c Defined names with relative references
A name whose references have no $ was evaluated as if they were
absolute. In Excel such references are relative: defined from B2,
=Sheet1!A2 means "the cell to the left", and the file stores it
relative to A1 (Sheet1!XFD1, wrapping around the sheet's edge).

Name formulas with relative references are now moved by the using
cell's offset from A1, wrapping at the edges, and formulas.defineName
takes relative references as relative to the active cell, as Excel's
Name Manager does, and stores them relative to A1.
2026-10-11 13:53:26 -05:00
nihilist-narwhal 85ca8f6af5 INDEX: arrays of row and column numbers pick a value each
INDEX took one row and one column number, so INDEX(A1:C3,{1;3},{1,3})
gave a single value and SUM(INDEX(A1:C3,{1,2,3},2)) summed one cell.
In Excel, arrays of positions return an array of the values at each
(broadcast) position. A position that picks a whole row or column gives
#CALC! there, as arrays can't nest.
2026-10-11 13:52:23 -05:00
Brandon Thomas 336deb87de Merge pull request #216 from MarekSpodBiedry/issue-202
Read the browser clock for NOW(), TODAY() and year-less dates on wasm (#202)
2026-10-11 12:46:33 -04:00
Brandon Thomas 2dbf6d43cf Merge pull request #352 from Fly-Style/fix/memory-alloc-on-agg
Get rid of double range copy for aggregation operations over ranges

# Conflicts:
#	crates/calc/src/tests.rs
2026-10-11 12:04:16 -04:00
Brandon Thomas 90dcc2b329 Merge pull request #297 from vangel76/feat/trimrange
Functions: TRIMRANGE (#296)

# Conflicts:
#	README.md
#	ROADMAP.md
#	crates/xlsx/src/tables.rs
#	docs/function-parity.md
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-11 11:44:50 -04:00
Sasha Syrotenko f7f7769460 Fold 'SUM', 'COUNT' and 'AVERAGE' over ranges in place 2026-10-11 17:50:30 +03:00
vangel76 52921512c7 Functions: TRIMRANGE (#296)
Evaluator special form returning the range (or array) without its blank
outer rows and columns; trim codes 0-3 as documented; written as
_xlfn.TRIMRANGE. The trim-reference operators are not parsed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:11:25 +02:00
nihilist-narwhal 060889afb2 CHOOSE: pick element by element when the index is an array
The evaluator took a single index, so CHOOSE({1,2},"a","b") gave "a"
and VLOOKUP(x,CHOOSE({1,2},B1:B3,A1:A3),2,FALSE) gave #REF!. An array
index now goes through the library CHOOSE, which lifts over its
arguments as Excel does.
2026-10-10 22:52:58 -05:00
MarekSpodBiedry 27be1753c2 Read the browser clock for NOW(), TODAY() and year-less dates on wasm (#202) 2026-10-10 23:43:32 +02:00
Brandon Thomas 49fbae245a Merge pull request #156 from nihilist-narwhal/fix-legacy-array-formulas
Legacy array formulas: fill the range they were entered in

# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	crates/ui-egui/src/formula_bar.rs
2026-10-10 15:47:56 -04:00
Brandon Thomas dd185ff12f Merge pull request #157 from nihilist-narwhal/fix-blocked-spill-dependents
Recalc: formulas over a blocked spill see #SPILL!; no spilling in tables
2026-10-10 15:47:12 -04:00
Brandon Thomas c3cfc92186 Merge pull request #155 from nihilist-narwhal/fix-range-blank-cells
Ranges keep their size past the used range; spilled blanks show 0
2026-10-10 15:47:12 -04:00
Brandon Thomas 9fea6235f7 Merge pull request #148 from nihilist-narwhal/fix-stale-recalc-values
Recalc: stale spills and values after edits (fixes #133, #139)
2026-10-10 15:47:12 -04:00
Brandon Thomas b5fe936d83 Merge pull request #66 from LloydNicholson/fix/recalc-range-dependents
Issue #122: Recalc: linear in the number of range formulas
2026-10-10 12:40:00 -04:00
Brandon Thomas 86328ec843 Review fix-up: ignore the wall-clock scaling test by default; check its values every run
recalc_with_range_formulas_scales_linearly is flaky under load, so it is #[ignore]d (run with
--ignored in release). A small range_formulas_recalc_and_bulk_edit_correctly test keeps the
helper's value check in every run. The helper reads Instant::now, so it allows
clippy::disallowed_methods (required once #125's clippy.toml lands).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:18:59 -04:00
Brandon Thomas 11472b340b Merge remote-tracking branch 'origin/main' into fix66 2026-10-10 12:11:09 -04:00
Brandon Thomas 15e0f0b6be Merge remote-tracking branch 'origin/main' into pr125 2026-10-10 12:04:21 -04:00
Brandon Thomas 453882c6fe Merge pull request #67 from LloydNicholson/fix/sumif-sum-range-resize
Issue #123: SUMIF/AVERAGEIF size the sum range like the criteria range
2026-10-10 11:57:13 -04:00
nihilist-narwhal e5d34fd63a Recalc: dynamic arrays don't spill in tables
Excel doesn't support spilled arrays in tables: a formula such as
=SEQUENCE(2) entered in a table cell shows #SPILL! ("Spill range in
table"). GridCraft spilled it over the table's cells. An array whose
formula is in a table is now blocked, both when it is computed and when
it is written back.
2026-10-10 05:40:36 -05:00
nihilist-narwhal 97f7dca886 Recalc: INDIRECT and OFFSET see spills laid out in the same pass
A formula whose references are only known while evaluating isn't in the
dependency graph, so when it read a spill area before the array was laid
out in the same pass, nothing evaluated it again. With the full
recalculation now laying every spill out afresh, =INDIRECT("F7") over
a spill moved by an inserted row read 0 after Calculate Now. Such
formulas are evaluated once more when a pass computed any array.
2026-10-10 04:59:27 -05:00
nihilist-narwhal be8e379ca0 Recalc: formulas over a blocked spill see #SPILL!
A dynamic array that couldn't spill was only rejected when results were
written back, after formulas evaluated later in the same pass had read
the array as if it had spilled. With 1s in A1:C3 except B2 =
SEQUENCE(2,2,9) (blocked), =SUM(A1:C3) gave 47 after a full
recalculation (opening a file, Calculate Now) instead of #SPILL!; a
reader through OFFSET kept the wrong value even when entered one cell
at a time. The spill area is now checked as soon as the array is
computed, so the anchor is #SPILL! for everything after it.
2026-10-10 04:45:41 -05:00
nihilist-narwhal 303eabaad1 Recalc: an array that read another array's old spill is evaluated again
When two dynamic arrays were recalculated in the same pass and the
first read the second's spill area before the second was evaluated, it
read the old values. Formulas reading a spill area that changed are
evaluated again, but dynamic arrays were left out of that second round
(only to keep an array from re-queuing itself), so with D11 =
SEQUENCE(1,3,A1) and G5 = UNIQUE(VSTACK(F9:F12,A1)), changing A1 from 1
to 10 left 3 in G6 instead of 12. Only the array itself is left out
now.
2026-10-10 04:42:51 -05:00
nihilist-narwhal 880163d135 Recalc: a spill that goes away unblocks the arrays it blocked
With =SEQUENCE(5) in F8 and =SEQUENCE(1,3) in E10 (blocked by F10),
typing 0 into F9 blocks F8's array, which frees F10, but E10 stayed
#SPILL! until a full recalculation. When a formula stops spilling over
an area, #SPILL! anchors above or to the left of it are evaluated again.
2026-10-10 04:36:06 -05:00
nihilist-narwhal e51c1d98fb Recalc: clearing a distant blocker unblocks the spill
Clearing a cell re-evaluated #SPILL! anchors only within 64 rows and
columns above and to the left of it, so =SEQUENCE(100) in A1 stayed
#SPILL! after the blocker in A90 was cleared. Emptied cells now look
for blocked anchors any distance above or to the left, with one pass
over the formulas per sheet however many cells were cleared.

Fixes #139.
2026-10-10 04:31:28 -05:00
nihilist-narwhal dcc5434bc8 Recalc: structural edits don't leave old spills behind
Inserting or deleting rows and columns moved formula cells but not the
values they had spilled, and the full recalculation that follows
reconciled spills only at the formulas' new positions. =SEQUENCE(3) in
A5 with row 1 deleted showed #SPILL! in A4 (blocked by its own old
values in A6:A7); deleting the anchor's row left 2 and 3 behind. A full
recalculation now drops all spills and lays them out again from the
formulas.

Fixes #133.
2026-10-10 04:27:44 -05:00
nihilist-narwhal fc2f00bd50 Recalc: a range ending in INDEX depends on the cells it spans
=SUM(C3:INDEX(B9:C11,2,2)) adds C3:C10, but only C3 and B9:C11 were
recorded as its precedents, so typing into C5 left the total unchanged
until a full recalculation. A range whose ends are function results now
depends on the box spanned by the references inside it, which contains
any cell those functions can return.
2026-10-10 04:24:42 -05:00
nihilist-narwhal 48910d71d2 Recalc: replacing or clearing a spilling formula removes its spill
Typing a value over a dynamic array formula, or clearing it, left the
values it had spilled: with =SEQUENCE(3) in A1, typing 2 into A1 kept
2 and 3 in A2:A3, and =SUM(A1:A3) gave 7. Only formulas are evaluated
again, so the old spill of a cell that no longer holds a formula was
never written back. It is now removed when the formula goes, and the
formulas reading its area are recalculated.
2026-10-10 04:24:41 -05:00
nihilist-narwhal e5af1fcbff Spilled blank cells show 0
A formula's result that is a blank cell shows 0, but blanks inside an
array result spilled as blank cells: =A1:A3, or FILTER or SORT of a
range with blanks, left empty cells that ISBLANK saw as blank and
COUNT skipped. Excel shows 0 in each. GROUPBY and PIVOTBY keep the
blank cells of their own layout.
2026-10-10 03:19:45 -05:00
nihilist-narwhal 62b17379e3 Ranges keep their size past the used range
A multi-cell reference was read only up to the sheet's used range, so
the array it gave depended on what else was on the sheet:
ROWS(A1:A10*1), COUNTBLANK(A1:A10) and SUMPRODUCT(--(A1:A10="")) came
out smaller than in Excel, and changed as cells were entered elsewhere
without the formula recalculating. References of up to 16,384 cells now
keep their size; larger ones (A:A, ranges reserved for data to come
such as $A$2:$A$50000) still stop at the used range, so that formulas
over them stay fast.
2026-10-10 03:19:45 -05:00
lloyd e893621d76 Web app: committing a cell no longer traps on the clock
Every edit goes through cmd::commit, which read std::time::Instant::now()
up front for GRIDCRAFT_PROFILE timing, whether profiling was on or not.
wasm32-unknown-unknown has no clock: Instant::now panics there, and with
panic=abort the browser reports `RuntimeError: unreachable`. So in the
web app, typing 1 into A1 and pressing Enter trapped (#118). The native
apps and the CLI have a clock and were unaffected.

commit now reads the clock only when GRIDCRAFT_PROFILE is set, and
never on wasm. The other clock reads (NOW(), the current year for typed
dates, the UI's frame timer) already skip wasm.

Native tests can't see this trap, so clippy.toml now disallows
Instant::now and SystemTime::now. A clock read has to be gated off wasm
and allowed at the call, and `cargo clippy -D warnings` fails on the old
commit code. The existing, already-gated reads and the native-only perf
example allow it.

Checked by building the engine for wasm32-unknown-unknown into a small
cdylib that runs cell.set A1=1 on a new workbook, and running it in
Node: before the fix it traps with `RuntimeError: unreachable`, after
it returns normally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:03:33 +02:00
nihilist-narwhal c650c2828f Legacy array formulas: fill the range they were entered in
A Ctrl+Shift+Enter array formula was evaluated like a dynamic array:

- Read from a file, the cached values in its range were kept as
  constants, so the formula showed #SPILL! (any multi-cell array
  formula saved by Excel or LibreOffice).
- Entered over C2:C4 with a two-value result, C4 stayed blank where
  Excel shows #N/A, and a single-cell array formula spilled.
- On save, the values of its other cells were not cached.

The result is now fitted to the range: a single row or column repeats
across it, cells past the result show #N/A, and a larger result is cut
off. Reading drops the cached values inside the range, entering clears
the range, and saving caches its values. The formula bar shows the
formula in braces, {=B2:B3*2}.
2026-10-10 01:53:55 -05:00
nihilist-narwhal 9c29ef3fff Formulas: the spill range operator A1#
formula::parse("Sheet1!$D$1#") failed with "unknown error literal".
A # right after a cell reference is now the spill range operator: it
evaluates to the anchor's current spill range, or #REF! when the cell
doesn't spill (or its spill is blocked). Its users depend on the
anchor, so they recalculate when the spill grows or shrinks. Names can
use it.

Files have no # operator: like Excel, the writer stores A1# as
_xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#.
2026-10-09 23:29:35 -05:00
lloyd 631a49ec30 Recalc: linear in the number of range formulas
Opening a workbook with a SUM(E:J) on each of 50,000 rows took 9 s
(1.7 s at 25,000, 0.4 s at 12,500), and editing a cell that 50,000
VLOOKUPs read froze the app for 40 s or more. Two things grew with the
number of ranges:

- Evaluating a range trims it to the sheet's used range, and
  Sheet::used_range walks every stored row. It ran once per range read,
  so a full recalc was rows x range formulas. The pass's host now works
  it out once per sheet (the workbook doesn't change during a pass);
  Host::used_range keeps the old behaviour for other hosts.
- The reverse index kept each column's range precedents in a Vec:
  finding a cell's dependents scanned every range in the column, and
  re-entering a formula ran a retain over each of its columns. Pasting
  a column, filling formulas down or sorting was quadratic. Each column
  (and each sheet's wide ranges) now has an interval index: sorted by
  start, with a segment tree of the largest end, so a lookup costs
  O(log n + matches). Removals mark entries dead and insertions wait in
  a small sorted set; Graph::settle merges them after each batch, so
  edits cost O(log n) amortised. Single-cell dependents are a set, so
  removing one is O(1). dependents_of_range asks each column once
  instead of once per cell.

Semantics are unchanged: whole rows and columns, other sheets, names,
tables, spills and volatile functions go through the same paths.

Release CLI, open + full recalc: 12.5k rows 0.39 s -> 0.06 s, 25k 1.83 s
-> 0.14 s, 50k 9.3 s -> 0.28 s. The 50k x 20 bug-hunt workbook: recalc
on open 87 s -> 1.7 s, editing C2 (100k dependents) 51 s -> 1.3 s;
filling 50k SUMs down 17 s -> 0.18 s, sorting 50k rows 9 s -> 0.14 s.

Tests: the interval index against a linear scan (inserts, removals,
revivals, merges) and its query cost in tree nodes; range, wide,
whole-column, whole-row and cross-sheet dependents after edits; and a
scaling test (open, change a column, re-enter every formula) that
allows 8x the time for 4x the rows. Before the fix it took 16x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:51:15 +02:00
lloyd 8fe3276cb8 SUMIF/AVERAGEIF size the sum range like the criteria range
Excel adds up the range that starts at sum_range's top-left cell and has
the criteria range's height and width, whatever size sum_range has. With
A1:B3 = a,1 / b,2 / a,3, =SUMIF(A1:A3,"a",B1) is 4; GridCraft gave 1,
because if_numbers only saw sum_range's values (one cell) and read them
at the criteria positions. Its comment promised the resizing, but by then
the argument was a plain value.

The evaluator has the references, so it now does the resizing, as it does
for OFFSET and INDEX: for SUMIF and AVERAGEIF with three arguments, a
single-area sum range reference becomes resized_sum_range(sum, criteria
height, criteria width), cut off at the sheet's edge. It works across
sheets and through names. The dependency graph records the resized
range, so editing a cell inside it recalculates; when the criteria range
isn't a plain reference the formula is recalculated every time instead.
SUMIFS and AVERAGEIFS keep requiring same-size ranges (#VALUE!
otherwise).

Tests: a single-cell, shorter, wider, longer and horizontal sum range,
whole-column criteria, editing a cell of the resized range, the sheet
edge, another sheet and a defined name, and SUMIFS/AVERAGEIFS size
mismatches. They fail before the fix (1 instead of 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:44:18 +02:00
Brandon Thomas 488e12de31 Rename SheetCraft to GridCraft everywhere (crates, apps, bundle ids, packaging, docs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:39:08 -04:00
Brandon Thomas 168b8ec8b7 M4–M10: PivotTables, PDF export & printing, what-if analysis, spelling, more commands
- PivotTables: engine (grouping, date groups, multiple values, show-as, filters, layouts,
  refresh, recommended pivots) + PivotTable Fields task pane
- sheetcraft-pdf (dependency-free PDF writer, core-14 fonts) + file.exportPdf/print/
  printPreview/pageLayout.pageSetup with pagination, headers/footers, charts, images
- What-if: Goal Seek, Scenario Manager, data tables, advanced filter, consolidate
- Page Break Preview rendering; view mode buttons
- Spelling (system dictionary), icons library, cell checkboxes, recorded scripts,
  chart design/format commands, arrange, custom views, watch window, Get Data from CSV/JSON
- Native macOS menu bar
- UI interaction tests (kittest): fixed arrow keys losing grid focus and Escape not cancelling
- Perf: FxHash in calc, sparse range reads, Arc'd formula ASTs, fast auto row heights
- Parity 254/290 (87.6%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:04:46 -04:00
Brandon Thomas beb353943e M1: Excel-style egui UI, desktop app, chart renderer, MCP server, CLI, release packaging
- ui-egui: title bar with QAT, full ribbon (Home/Insert/Draw/Page Layout/Formulas/Data/
  Review/View/Automate + Table/Chart Design), formula bar with Name Box, virtualised grid
  (frozen panes, overflow, borders, CF data bars/scales/icons, tables, sparklines, filter
  buttons, charts/pictures/shapes), in-cell editor with point mode, reference colouring,
  autocomplete and argument hints, sheet tabs, status bar, dialogs, control channel
- apps/sheetcraft: desktop app (control server, file dialogs, prefs, drag & drop)
- chart: toolkit-free chart layout/rendering for 25 chart kinds + sparklines + rasteriser
- mcp + sheetcraft-cli: MCP server (headless or bridged) and CLI (info/convert/eval/cat/run/mcp)
- xtask (ci/layers/assets/wasm/version/parity), release workflows, packaging, placeholder icon
- automatic row heights

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:00:15 -04:00
Brandon Thomas 1ba9daa263 M0: foundation — core, numfmt, formula, functions (478), model, calc, xlsx/csv, engine
Clean-room spreadsheet engine: addresses/values/dates, Excel number formats,
formula parser/printer/reference adjustment, worksheet function library,
copy-on-write workbook model, dependency-driven recalculation with dynamic
arrays, XLSX/CSV I/O, and a command engine (cell entry, clipboard, fill,
formatting, sheets, sort/filter, tables, charts, CF, names, view, file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:18:10 -04:00