jump() searched for the next filled cell by stepping (dr, dc); with
neither set, the step never moved, so selection.move {"jump":true}
from a blank cell of a sheet with content looped forever (found with
an agent call that left out dr and dc). It now returns the cell it
started from.
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>
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}.
A scatter or bubble chart whose X values are text (a first column of
labels) drew no points, as none of its X values were numbers. Like
Excel, such X values are now ignored and the points numbered 1..n.
chart.switchRowColumn did nothing on a chart read from a file: only
charts made by insert.chart keep their source range. Without one, the
command now takes the block the series' names, categories and values
span (when they're all on one sheet), and keeps it.
A combo chart whose series have no kind of their own (as insert.chart
makes them) was written as one column group, and a stock chart as
clustered columns, so both reopened as clustered columns. Combo series
are now written as drawn (the first as columns, the others as lines),
and stock charts as a stockChart group (as lines when they don't have
the 3 or 4 series a stock chart needs).
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#.
The parser upper-cases function names, so =Double(4) showed as
=DOUBLE(4). Calls of LAMBDA names are now spelled like the name when
a formula is entered and when a file is read.
formulas.createFromSelection ignored bottom and right, and replaced any
workbook name with the same label without asking. Bottom-row and
right-column labels now name the cells above and to their left. An
existing name is kept and listed in the result's skipped, unless
replace is set (Excel asks; a command doesn't open dialogs).
Delete Cells built the references' edit and then discarded it, so
references into the deleted cells didn't become #REF!, and Insert Cells
treated the shift as a move of everything below, so =SUM(A1:A5) didn't
grow to A1:A6 when A3 was inserted.
Two new structural edits, InsertCells and DeleteCells, adjust a
reference like inserting/deleting rows (columns) when it lies within the
shifted columns (rows), and leave it as it is otherwise, as Excel does.
Formulas, defined names, chart series, conditional-format, validation,
table and print-area ranges, notes, links and sparklines follow.
On a protected sheet a formula formatted Hidden still showed with Show
Formulas on, matched Find in formulas, and showed in the formula bar
when a cell of its spill range was selected.
Only cell.set checked protection. The check now runs where every edit
is committed: an edit that changes the value or formula of a locked
cell on a protected sheet is refused with Excel's message, whichever
command made it (clear, paste, fills, setValues, replace, goal seek,
sort, insert/delete cells...).
Inserting and deleting rows and columns follow the protection's
options, and deleted lines must not hold locked cells.
Moving a sheet left its sheet-level names on whatever sheet took its
index, and copying it shifted nothing and copied no names. Moving now
re-indexes name scopes, and a copy gets copies of the sheet's names,
referring to the copy, as in Excel.
Deleting a sheet left names that referred to it pointing at nothing;
they now become #REF!. formulas.deleteName honours scope, and the Name
Manager's Delete passes it.
They are not error codes of the file format, and Excel refuses to open a
file whose cached values contain them ("Parameter error"). Any workbook
with a blocked spill could not be opened in Excel. Excel caches these
errors as #VALUE! itself.
Adds PtBr to the interface-language system from #31: a PORTUGUESE table
covering the same 259 chrome strings as JAPANESE (a test keeps the two
tables in step), Excel's pt-BR wording throughout, "pt"/"pt-br" codes
(serde-stored as "pt"), Português (Brasil) in the switcher, the
app.language.portuguese command, and first-run detection of a pt-BR
system locale via the existing tag reduction.
Reserve a strip beside the virtual grid for a theme-aware vertical
scrollbar. Support paging, dragging and wheel scrolling without changing
the active cell. Keep the scroll extent fixed during thumb dragging.
Co-authored-by: Codex <noreply@openai.com>
Point both contributor instruction files to the UI crate's snapshot
example and identify it as a command to run from a source checkout.
Verified the documented command produces a readable sales dashboard PNG.
Co-authored-by: Codex <noreply@openai.com>
Resolve input styles from the destination cell, row or column before
parsing. Preserve inherited alignment, borders and number formats for
single-cell and bulk entry, including text paste and array formulas.
Co-authored-by: Codex <noreply@openai.com>
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>
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>
Add support for Excel's "smoothed line" chart style. The smooth flag flows
model -> resolve -> chart render, persists through XLSX (<c:smooth val="1"/>),
and is toggled via the chart.addElement "smooth" element.
Rendering interpolates each gap-free run with a uniform Catmull-Rom spline
(0.5 tangent scale, 100 sub-segments per data segment), so the curve passes
through every data point and rounds the vertices — matching Excel's observed
behaviour. Long runs are still decimated before smoothing.
- model: Series.smooth (#[serde(default)], so old workbooks load unchanged)
- chart: SeriesData.smooth; smooth_run + catmull in render; wired through
resolve and the sanitize() copy; draw_runs takes the flag (line + scatter)
- engine: chart.addElement element="smooth"
- xlsx: read/write <c:smooth val> instead of hard-coding val="0"
- tests: smooth_line_adds_interpolated_points_and_keeps_endpoints,
smooth_line_splits_at_gaps, smooth_line_setting_round_trips
- examples/smooth_demo.rs: generates a straight+smooth side-by-side workbook
cargo xtask ci: all 7 steps pass.
serial_from_ymd worked out the real calendar date and then moved serials
up to 60 down by one. February 1900 has no 29th in the real calendar, so
DATE(1900,2,29) and DATEVALUE("2/29/1900") rolled over to 1 March (61),
and DATE(1900,3,0) gave 28 February (59). Excel's 1900 system keeps the
Lotus leap day: serial 60 is 29 February 1900, and DAY(60) already
said so.
The serial of the first of the month is now taken in Excel's calendar
(one lower up to February 1900), and the day, with any overflow, counts
on from there. So day and month overflow pass through serial 60 both
ways: DATE(1900,1,60), DATE(1900,4,-31) and DATE(1901,-10,29) are 60 too.
Month and day arithmetic is checked, so hostile values return None.
days_in_month_in gives February 1900 29 days in the 1900 system; EDATE
and EOMONTH use it, so EOMONTH(60,0) is 60 rather than the day before,
and the text parser uses it in place of its own special case. The 1904
system is unchanged (1900 is before its epoch).
Tests: serials around the leap day (overflow both ways, every serial
0-366 round-trips through its year, month and day), hostile months and
days, parsing 2/29/1900 and 1900-02-29, DATE/DATEVALUE/EDATE/EOMONTH
in the 1900 system and the 1904 system. The DATE, DATEVALUE and serial
cases fail before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
grid.rs measured the digit width once, for an 11pt cell font, and
display_text judged how many General digits fit from that whatever the
cell's size. 9pt marks such as 100 in columns with a stored width of 4
(common in mark sheets) came out as ##. The width is now measured per cell
with its own font and size, as print.rs already does; the shared 11pt
measurement and its paint_quadrant parameter go away.
Test: a 9pt 100 in a 28px column draws in full, while 123456789 next to it
still shows ####. Fails before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Narrow columns with tall, upright headers (mark sheets) set both a text
rotation and Wrap Text. The wrap path drew the text flat and returned before
the rotation was applied, so headers came out as 3-4 character fragments.
- Rotated text no longer takes the flat wrap path. Upright (+/-90 deg) text
wraps to the row height, its lines stacking across the column; other
angles keep one line, as before.
- Downward text was anchored at the bottom-left and ran out of the cell
(over the row headers); it now starts at the top, one block-height in.
- Upright text no longer overflows into empty neighbours: its width is its
line height, and the overflow repainted their gridlines.
- Print/PDF gets the same three fixes.
Tests: ui-egui interaction test (shapes drawn at +/-90 deg, inside a
one-column clip) and a print test (every text block on the page rotated,
a few long lines each). Each failed before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui bundles only Latin glyphs, and the fallback chain appended just the
Han/Kana and Hangul faces, so Thai and Hebrew text rendered as tofu boxes
even though the text was read correctly.
Generalise the CJK fallback into a per-script fallback: every family now
also carries a Thai face (Leelawadee UI / Tahoma / Thonburi / Noto Sans
Thai) and a Hebrew face (David / Arial Hebrew / Noto Sans Hebrew). epaint
shapes the complex scripts itself via harfrust -- it only needed a face
that has the glyphs. The faces stay at the end of each chain, so Latin
still wins for Latin text.
The tests become script-general: a structural check that every family
ends with the loaded fallback faces, and a per-script render check that
the sample string resolves to a real glyph when the face is present.
Full bidi reordering of mixed LTR/RTL text is a separate epaint limitation
(no Unicode Bidi Algorithm); a pure other-script run is shaped correctly.