Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A checkbox cell drew a box on screen but printed and exported to PDF
its value as TRUE or FALSE. The print path now draws the box as the
grid does: white with a grey outline when unchecked, green with a
white tick when checked. Other values in a checkbox cell still print
as text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e6df6f42 removed the #[allow(unused_imports, dead_code)] on the
native_menu module that tests/mac_menu_appkit.rs builds in. That test
only compiles on macOS, so `cargo xtask ci` has failed at clippy on
every Mac since, while the Linux gate stayed green:
- the test called only `install`, leaving `poll`, `sync_windows`,
`select_theme`, `WINDOW_ITEM` and most `NativeMenu` fields unused;
- the harness = false build has cfg(test) but drops #[test] functions,
so the unit tests' `use super::*` was an unused import.
The test now also polls the menu with a one-workbook session and checks
that the Window menu lists that workbook (#716), which uses the rest of
the module. The unit tests name `super::` items instead of a glob import.
Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with
CRAFT_FONTS_DIR at the gate's craft-fonts commit), and the new check
fails when the poll is removed (Window menu's last item "" vs "Book1").
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1f9d55, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).
craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.
windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
Covers the case the fix exists for: an automation keystroke presses and
releases within one frame, so the frame-wide modifier state is already
empty and only the press event carries Command. With the modifiers read
from `i.modifiers` this assertion fails (B3 instead of B1).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* packaging: Flatpak finds and prints to CUPS printers
The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.
* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)
Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.
* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path
Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.
* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted
Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.
* packaging: never redirect a CUPS_SERVER the sandbox was given
Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.
* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail
Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
While a formula reference is being pointed from the keyboard, Ctrl+arrow
(⌘arrow on a Mac) moved the pointed cell by one, and Ctrl+Shift+arrow
extended the pointed range by one cell, so `=COUNTIF(`, a click on the top
of a column and ⌘⇧↓ pointed at two cells instead of the column of data.
Both now go to the edge of the data region, as the same keys do on the
selection outside an edit: `point_move` takes `jump` and uses the engine's
`selection::jump`, which Ctrl+arrow on the grid already uses, so the two
agree (last filled cell of a run, else the next filled cell, else the sheet
edge). A pointed range that reaches the sheet edge is written as whole
columns or rows (`A:B`), as Excel writes it.
The editor used to read Shift from `i.modifiers`, the modifier state when
the frame's input was collected; an arrow pressed and released within one
frame (a control client's `ui.key`, an automation tool's keystroke) had
already lost it. It now reads the modifiers carried by the arrow's own key
event, as the grid's keyboard handler does.
Test: ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data (down to the
last filled cell, right across the data, down to the sheet edge, Ctrl+Up
without Shift to the next filled cell, then a plain arrow).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found while testing crates/engine/src/fill.rs:595-599
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Since #2425 checks Agile encryption's dataIntegrity, every workbook Excel
encrypts fails with "the decrypted data failed its integrity check". The
reader keyed the HMAC with the decrypted encryptedHmacKey cut to the salt
size (16 bytes), as [MS-OFFCRYPTO] 2.3.4.14 describes it, but Excel's key
is as long as the hash: 64 bytes for SHA-512. The check now accepts a key
of either length, so spec-literal writers still open and a modified
package still fails.
The test encryptor writes the integrity key as Excel does, so every
encrypted fixture now has Excel's layout; a new test covers both key
lengths, a key of neither length, and tampering with each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the macro interface: the Macros dialog and editor, the
Automate ribbon section, drag-and-drop of macro steps, and the
macro overlay on the grid. Ported into the split module layout on main.
Clippy gate: moved wildcard imports to explicit lists, named the
test edit tables, boxed the overlay layer tuple, switched the editor
to craft_ui::ComboBox per clippy.toml, and simplified an is_some_and
in the LP solver.
* Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds
Cross-built FreeBSD aarch64 archives were not possible: package.sh took the
arch from uname -m (the x86_64 VM host), built for the host, read binaries
from target/release, and ran the aarch64 gridcraft-cli to print its version.
When CARGO_BUILD_TARGET is set, build with --target, read from
target/<triple>/release, name the archive by the target's arch (aarch64 for
aarch64-unknown-freebsd, x86_64 otherwise), and skip running the binary.
Unset behavior is unchanged.
* Reject unsupported CARGO_BUILD_TARGET in FreeBSD packaging
Only x86_64-unknown-freebsd and aarch64-unknown-freebsd are valid; any other
value now fails before the build or packaging.
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
Saving a large workbook spent about half its time building worksheet XML,
mostly in small per-cell allocations. The writer now appends to the part
directly:
- Cell references, row numbers, style ids, shared string indexes and
numbers are appended without temporary strings (push_a1, push_u64,
push_num), and text is escaped in place (push_esc, with a fast path for
plain ASCII).
- sheetData walks the cell store in its own row-major order instead of
copying every address into a BTreeSet and a per-row BTreeMap and looking
each cell up again. Cells outside the store (pictures, spill values,
table headers) still go through the sorted set.
- Formulas with nothing the file spells differently (no LET/LAMBDA, no
_xlfn function, no spill, @, trim operator or structured reference) are
printed from the shared expression instead of being cloned and rebuilt
twice by the file rewrite.
- The A1 printer in gridcraft-formula appends references to the output
instead of formatting each anchor into its own string.
The output is byte-for-byte unchanged.
write_perf, 200,000 rows (2.0M cells, 14.2 MB package), release, Ryzen
16 threads / Linux, 36 interleaved runs each:
before: median 2083.9 ms, min 1615.7 ms
after: median 1501.3 ms, min 1192.3 ms (-28.0 % median, -26.2 % min)
What remains is mostly deflate (zlib-rs, level 6), about 70 % of the save.
Tests pin the exact sheetData of a workbook covering row settings between
cell rows, spill values, every value kind, number spellings, escaping and
formulas with and without a file spelling (generated with the previous
writer), and check each appending helper and the formula fast path against
the code they replace.
The formula fast path is guarded by a test that runs both paths over every
function in the gridcraft-functions registry (a new dev-dependency, allowed
by the layer rules), in 15 surrounding shapes covering every operator and
rewrite, so a function or rewrite added later without updating the trigger
list fails it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:
- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
per-machine and skipped the existing per-user copy. An interactive upgrade
left two registered products and uninstall left files behind. The page now
leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
so choosing "Everyone" in the wizard installed into the user profile with a
machine-wide registration. The wizard now sets the new-install folder itself
after the choice (Program Files, Program Files (x86) for 32-bit, or the
per-user Programs folder). Silent installs are unchanged.
check-install-policy.ps1 now fails if either regresses.
* fix(ui-egui): dropdown menus regrow while open
Use craft_ui's ComboBox and combo::Menu so a menu that shrank (a filter with
no matches) grows back when the filter is cleared (egui#5225). Repin
craft-libs to one revision.
* chore(deps): craft-libs 60d60d48 (dropdown fixes)
* chore(deps): craft-libs 6af63481 (dropdown fixes)
* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)
* chore(deps): craft-libs 92d392cf (#387 merged)
`cargo run --release -p gridcraft-xlsx --example write_perf -- [rows] [runs] [out.xlsx]`
builds a sales-style workbook (order ids, dates, categories, numbers, two
formula columns with cached values, booleans, sparse notes, a summary sheet)
and times write_xlsx, printing each run and the median / minimum. An optional
output path keeps the last package for byte comparisons between builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
authenticate() set the read timeout on the writing half of the socket while it read the
token through a try_clone; on Windows the clone has its own timeout, so a peer that never
sent a line held its thread forever (and a per-read timeout let a byte-at-a-time peer
stretch it elsewhere). The first line now has one total deadline, set on the handle being
read and shortened before each read. At most 16 connections may wait to authenticate (more
are closed on accept, as #2174 suggested), and accept errors back off instead of spinning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lookup lifted over an array of needles searched its whole lookup vector once per needle
with no total cap, so =ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576))) in a 1.6 KB file
ran for hours and hung file.open. VLOOKUP, HLOOKUP, LOOKUP, MATCH, XLOOKUP and XMATCH now
return #NUM! past 5e8 needle x entry steps, the pattern MMULT uses. Other lifted functions
are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
window.open(url, "_blank,noopener") named the new window "_blank,noopener" and passed no
features, so a page opened from a workbook link kept window.opener and could navigate the
editor tab. Pass "noopener,noreferrer" as the features argument instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`save_fails_upfront_when_the_temp_name_is_taken` calls
`std::os::unix::fs::symlink` but was only gated on non-wasm, so the
engine crate's lib tests did not compile on Windows. Gate it `cfg(unix)`
like the neighbouring permissions test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel computes IMSQRT through the polar form, so IMSQRT("-4") is
1.22514845490862E-16+2i rather than 2i. Tests assert strings read back
from Excel; the function-parity doc lists the remaining IM* differences.
* refactor: split complex functions for Sonar S3776
Extract helpers from backstage options (general, calculation and iteration
sections), code_editor python (string scanning), xtask i18n literals
(escape and string readers) and calc recalc loops_among (SCC search).
No behaviour change.
* Restore the loop count that loops_among's refactor dropped
The S3776 split moved the loop-detection body into loops_among without
the 'let n = circ.len()' it reads, so calc failed to compile and the
Sonar build broke. Restore the binding from main.
* Extract the SCC pop step from components() to reduce cognitive complexity (S3776)
Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
Main now keeps a dialog with its own workbook window, so the step editor is
parked with its workbook rather than drawn over another one, and closing that
workbook drops the editor without drawing it again: forget_closed_editor
clears it each frame. The Macros, Record Macro and editor text boxes are
labelled by their captions, and the tests pick the question's Cancel and the
chart menu's Delete by role, as the formula bar and ribbon share those names.
craft-update, craft-window, craft-ui and craft-print all come from one craft-libs revision, the
head of storytold/craft-libs#340 on the aiongg fork, until #340 merges.
Scripts can't run the View tab's window commands (Switch Windows, New Window, Hide, Unhide):
they change the active workbook, as window.* steps do. The Switch Windows button names its
accessibility role with egui::Role (egui 0.37).
Extract helpers from auto_sum (selection and single-cell guess), update_cursor
(drag, hover, freeze, and header-edge cursors) and read_styles (dxf reader).
No behaviour change.
The Chart Type section of the Format Chart Area pane listed nine types,
with stacked column as the only stacked one, so a chart could not be
switched there to stacked bar, 100% stacked column or bar, stacked
line, or stacked or 100% stacked area. It now lists them after their
family's entries, and "Stacked" is renamed "Stacked Column".
Each entry is drawn selected when it is the chart's current type, as
the Legend entries are, using the same type and subtype mapping that
chart.set applies.
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.
Part of tracking issue storytold/craft-libs#319
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.
Part of tracking issue storytold/craft-libs#319🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
* feat: startup update prompt modal and direct self-updating
The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status message. An install holds back while the workbook is unsaved.
Ref: storytold/craft-libs#319
* build: refresh the lock
* build: pin craft-update to craft-libs main (db19c37)
* fix(updates): address review feedback
Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.
* fix(updates): add Check for Updates… to the native macOS Help menu
* E37: migrate GridCraft to egui 0.37
Pin the reviewed fork and shared dependencies, adapt input and rendering to the
new frame lifecycle, and preserve bounded capture handling and font notices.
Add representative accessibility checks and meaningful control labels.
Validation: full repository CI passed all ten stages, with 2,249 tests passed,
zero failed and one existing timing test ignored. Native workflows and paired
performance qualification remain documented separately.
* egui037: record final published pins and local qualification
* M0.37: fix RTL clipping and preserve screenshot budgets
* test: share migrated GridCraft UI harness setup
* egui037: document migrated UI and regression contracts
* egui-037: align popup test fonts and avoid redundant tooltip probes
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Fix imports
* Fix serde imports
* Fix engine imports
* Fix clippy gate and a type error from the explicit-import change
Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.
Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Apply rustfmt to the explicit-import cleanup so the fmt gate passes
* Share the per-directory font path expansion in theme.rs
The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Read the Windows fonts folder in one place in theme.rs
Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Build the light and dark UI palettes from one table in theme.rs
Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Share the Noto CJK font list between the CJK script lists in theme.rs
The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Share Linux Noto directory list across Thai and Hebrew font lookups
Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.
* Reuse in_dirs for Thai and Hebrew Noto font candidates
Removes the remaining duplicated per-directory loops flagged by SonarCloud.
* Share one font-list builder for the Thai and Hebrew faces
The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Sheet1!A1:SHEET1!B2 is a range whose second end repeats the sheet, as
Sheet1!A1:Sheet1!B2 is: the check uses same_name, Excel's case-insensitive
name comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AutoSum over any selection of more than one cell wrote a total under each column, so a row
selected (B2:E2) got four one-cell totals in row 3 instead of =SUM(B2:E2) in F2.
As Excel does, totals now go in the selection's empty last row or column, and otherwise past
it:
- a single row: its total in its last cell when that is empty, else just to its right;
- a block whose last column is empty: each row's total there; whose last row is empty: each
column's; both: both, and the block's total in the corner;
- otherwise totals under each column, as before.
A whole-row selection still stops at the data's last column, and all totals are one undo step.
Tests: autosum_over_a_row_totals_it_to_the_right,
autosum_over_a_whole_row_totals_it_after_the_data and
autosum_fills_a_selections_empty_last_column_and_row; each fails without the change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`parse_shortcut` took the last letter of any spelling, so `"Ctrl+Alt+M"` was saved as
Ctrl+Shift+M. It now takes a bare letter or one after Ctrl+Shift, Cmd+Shift or ⌘⇧, and refuses
other modifiers.
- Test: `shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones`.
The example was K, which GridCraft uses (Group), so a caller who copied it was refused; it is M
now, in the command's description and on the macro page.
Saving over a macro now needs `replace: true`, and the engine's refusal says so in terms for
programs. A new draft whose name another macro has (or a kept one from a newer version) is
refused by the editor first, with "A macro called “three” already exists. Choose another name."
- Test: `a_new_macro_never_replaces_one_with_its_name`.
Saving a macro over one with the same name now needs `replace: true`, so Macro Options, which
changes the shortcut and description of a macro that exists, failed to save. It passes
`replace: true`; `options_set_a_shortcut_that_runs_the_macro` covers it.
Record Macro checks the Ctrl+Shift letter when recording starts, but
another macro can take it before recording stops; the save then leaves
the shortcut off, and nothing said so. The result now names it in
shortcutDropped, the toast after Stop says the macro was saved without
it, and the macro page describes it.
The format now refuses a single-cell input with a header, several column types or more than one
row or column, a least above its most, and more column types than columns; the editor could make
each of these, and the save then failed.
- Switching an input to "single cell" sets one row and one column, takes the header off and keeps
at most one column type; "Use current selection as example" does the same for one cell.
- Setting "at least" above "at most" moves "at most" up (and the other way round), and lowering
the columns' "at most" drops the column types past it.
- Test: `an_edited_input_keeps_a_shape_a_macro_may_declare` (each result passes `check_limits`).
The dialog's rows and its Options pane hold the macros of the workbook
it opened on, but Run, Delete and Options act on the active workbook.
When an agent made another workbook active, the dialog stayed open over
it, and OK in Options would write the first macro's shortcut and
description onto the other workbook's macro of the same name. The
dialog now keeps its workbook and closes when another one becomes
active.
The Questions entry says that an answer runs in the workbook that asked
and does nothing while another one is active. The lines the restack
joined in the recording paragraph and the Macros dialog entry are
wrapped again.
step_at copied the whole step list to look up one step, and the step
list calls it for every row on every frame, so a macro at the 10,000
step limit (which a recording can reach, or a file can carry) took about
ten seconds a frame. The lookup now walks the list without copying it:
five frames of that macro take well under a second. A test opens a
macro at the limit with a generous time bound.
The Macros dialog's Delete question and the questions a macro run asks
(approve it, overwrite or move the data in the way) keep the command
and run it again with the answer. When another workbook became active
before the answer (an agent can switch workbooks under the prompt),
the answer ran there: Delete removed the other workbook's macro of the
same name. A question now keeps its workbook, and an answer given while
another one is active does nothing and says why.
The editor's window had a minimum size of 660 by 380 and its toolbar
was one row of about 680 pixels, so in a window 600 pixels wide Run to
Here and Stop were off screen, and at 480 the inspector's tabs were cut
off. The window is now never larger than the app's window, the name row,
the toolbar and the inspector's tabs wrap (the toolbar's buttons are
disabled in place rather than in a scope of their own, which kept them
from wrapping), the inspector keeps room for its tabs, and its content
scrolls sideways instead of widening the window. Tests check the editor
at 600 and 480 pixels, and the Macros and Record Macro dialogs, which
already fit.
A window close (the close button, Alt+F4, quitting) let the app go when
every workbook was saved, losing the editor's unsaved steps, and with an
unsaved workbook it made that workbook active under the editor and asked
for a Save Changes question the editor's window would not let open, so
nothing happened. The close now waits for the editor: it asks about its
debug run and unsaved steps as its own close button does, Cancel keeps
the app open, and once the editor has closed the window close goes on
and asks about unsaved workbooks as before.
The step editor keeps its window until it closes itself, so a dialog
asked for while it is open is not shown. That was silent: File > Close
on a changed workbook asked for its Save Changes question and nothing
happened. The request now shows a message that says to close the
editor first.
ui.dialog.cancel emptied the dialog slot, so with the step editor open
it went without its cleanup: the draft stayed behind, the Check overlays
stayed on the grid, the selection the reference boxes moved was not put
back and a debug run went on with no controls. The cancel now closes
the editor the way its close button does, and is refused, with the
reason, while the editor holds unsaved steps or a debug run, which only
its window asks about.
The macro.* commands act on the active workbook, and the editor stays
open while another one becomes active (File > New, Switch Window, an
agent). A new draft saved then went into that other workbook, and Check,
Run and Debug ran there. The editor now keeps the workbook it opened in:
while another one is active it says so, its controls are disabled,
Save, Check, Run and starting Debug refuse with a message that names
the workbook, the grid overlays are hidden and closing does not put the
selection back on the wrong sheet. Closing the workbook closes the
editor.
The Macros dialog that replaces the scripts-only list keeps its Trust
Scripts row (#2178): scripts that came with an opened file are listed as
disabled until the row's button trusts them for the session, and Run
works after it. A test covers the row, the trust and the run, and the
macro page describes it.
Icon buttons no longer carry an accessible name: the tests that came
with main since the branch was written (menu hover, popup dismissal, the
File menu, More Colors) find Copy, Save and Cancel by label, and the
Quick Access Toolbar and formula bar icons gave those labels twice. The
macro tests need the names of large, small and toggle ribbon buttons
only. The chart menu test takes the menu's Delete, which is drawn after
the ribbon's Home > Cells > Delete. The Trust Scripts row ported into
the Macros dialog is formatted.
Choosing another step or inspector tab hid the picking box but left the pick in place, so a grid
selection made later for another reason landed in the box when its step was shown again, and the
draft was dirtied with a reference nobody chose. A pick now ends in any frame that does not draw
its box.
A reference box that had focus kept picking from the grid while Check, Run or a debugger action
ran. The selections those make landed in the box and dirtied the draft, and a later Check could
save that draft. The pick now ends when an action starts, and while a debug run is active a
selection on the grid is never taken as a pick.
Also: the parity assessment said an .xlsm's VBA project is dropped on open; it is kept unchanged
on save and never run.
In Change mode the picker hid the Repeat, If and Stop rows but still indexed the full
list, so Enter with an empty search (index 0, nothing highlighted) picked Repeat and
turned the command step into a Repeat step; a search for "if" or "stop" did the same.
The control rows are now dropped from the list before it is indexed, drawn or chosen.
The step editor lives in the app's one dialog slot, so Format Cells, the Macros
dialog or New Macro opened while it was up took its place. The draft was lost
(New Macro started a fresh one), and a closed-over editor left its grid overlays and
its debug run behind. While the editor is open, requests for other dialogs are now
ignored; the editor closes itself as before.
Save renames the stored macro first and then saves the draft. When the save failed
(a draft too large to keep in the workbook, say), saved_as still held the old name,
so every later Save, Check, Run and debug start tried to rename a macro that no
longer existed. saved_as now takes the new name as soon as the rename succeeds.
A new draft's name can be edited to that of a macro already in the workbook. Save, and Check and
Run (which save first), then sent macro.save with its default replace: true and the draft took
the other macro's place. A new draft now saves with replace: false, so the clash is reported and
the existing macro stays as it was.
New Macro (Automate › Macros) and Step Into, Edit and Create in the
Macros dialog open a resizable step editor. It works on a draft of the
macro and saves it with macro.save.
The step list shows each step's number, a breakpoint dot in the gutter
(click to set or clear), a category icon, a summary, an error marker
and an enabled checkbox; Repeat bodies and If branches are indented
under their step, and rows drag to reorder within their list. Add Step
opens a command picker (control steps first; commands a macro may
never run are left out) and fills in the command's required
parameters; Duplicate and Delete act on the selected step.
The inspector's Details tab edits the selected step. Reference
parameters are reference boxes that take a typed reference or a
selection on the grid, with a Rel/Abs switch between a token relative
to the example input and an absolute reference, and token suggestions.
The Inputs tab sets each input's shape, header and column types (Use
current selection as example infers them); the Output tab sets the
anchor and the conflict rule.
The debug bar has Check (a dry run that colours the grid: inputs blue,
writes green, writes over data red), Run, Step, Continue, Run to Here
and Stop, which asks whether to keep the changes or undo them. The
debugger receives the editor's breakpoints with breakpointsOnly, so a
dot cleared while paused takes effect at once; the current step is
selected, its parameters are shown as they will run, and its target is
outlined on the grid. While a macro is debugged, the status bar says so
and Show reopens the editor.
List drag and drop lives in a small dnd module. The debugger icons are
drawn in code, and the breakpoint and sheet overlay colours are theme
tokens covered by the contrast test.
The Automate tab gains a Macros group: Macros opens the Macros dialog,
Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut
letter, description, Use Relative References) and turns into Stop
Recording while recording, Use Relative References sets how the next
recording starts, and Run Macro runs the macro run last; its menu lists
the recent macros, then every macro. While recording, the status bar
shows a red dot, "Recording · N steps" and Stop.
The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the
workbook's macros with their shortcuts, then its Automate scripts, and
says when the file carries a VBA project, which is kept and not run.
Run, Delete (which asks first) and Options (shortcut letter and
description) act on the selected row. The dialog keeps the result of
view.macros, so the scripts and the VBA entry are listed as before.
A macro that needs approval asks with Run, and one whose output would
land on data asks with Overwrite, Shift Down and Shift Right; each
button runs the command again with its answer. Ctrl+Shift+letter
(Cmd+Shift on a Mac) runs the macro with that shortcut; the letters
GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV)
now open the Record Macro and Macros dialogs.
Icons for macros, recording and relative references are drawn in code,
the recording dot is a theme token covered by the contrast test, and
large and small ribbon buttons and icon buttons carry accessible names.
macro.record checks name, shortcut and description when recording
starts and keeps them for the save, so a name that is taken, too long
or holds line breaks, or a Ctrl+Shift letter that GridCraft or another
macro uses, is refused before anything is recorded. With empty
parameters it records under the next MacroN, as before. A name or
description given when stopping still wins, and a shortcut that another
macro took while recording is dropped.
The shortcut rules (macros::parse_shortcut, shortcut_text,
shortcut_conflict, RESERVED_SHORTCUTS and check_new_name) live in the
engine, so the Record Macro dialog, Macro Options and agents agree.
A group remembered the recording's length when it began, which another workbook's rollback (or a
recording started again inside the group) made point at the wrong steps. Each kept step now has
a number, and each recording one of its own; a group remembers both, and its rollback takes back
the steps of its workbook numbered from its mark, or all of them when the recording started
inside the group.
- Test: `rollbacks_take_back_their_own_recorded_steps_in_any_order` (groups on two workbooks
rolled back in turn, and a recording started again inside a group).
Taking back the steps a rolled-back group recorded left what a taken-back script or macro run
ran (`Recorder::ran`) under its old index, where a later run could pick it up, and left it out of
the size count. `cut_back` now drops it with its step, moves a kept run's entry to the step's new
index and counts both again.
- Test: `a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording`.
A rollback took back every step recorded since its group began, so an edit recorded in another
workbook while the group was open was lost from the recording although the edit stayed. It now
takes back only the steps recorded in the group's own workbook.
- Test: the other workbook's edit in `stop_action_runs_only_on_its_own_and_is_not_recorded`.
The pause of a dry run was cleared only by a panic at the top level. A command that started a
dry run, nested in another, and panicked left journaling paused for the rest of the session (and
the touch log open) once the outer command finished. Each command now keeps the pause count it
started with, and its panic guard puts it back, closing the touch logs, whatever its depth.
- Test: `a_dry_run_cut_short_by_a_panic_leaves_journaling_on`.
Every nested command a dry run executed was journaled. Past the journal's limits that evicted
the person's own oldest entries, and the restore could only cut back what was left, so one
`macro.check` of a long macro could empty the journal and move a Record Actions recording's
start. While `Saved` holds the session's state, commands are not journaled
(`Session::journal_paused`), and the restore puts the journal and the recording start back
exactly; the count of dropped entries it relied on is gone. The fallible calls of a dry run come
before the pause, and a panic at the top level turns journaling back on.
- Test: `a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were` (a full journal, a
Record Actions start, and a check of 12,000 nested steps; replaces the test that accepted the
loss).
`with_names` checked only the size of each item, so a macro built in code past the model's
limits (a name with a line break) was written to the file and dropped the next time it opened.
The xlsx save now runs `check` on every macro first.
- Test: the macro past the limits in `a_macro_built_in_code_goes_through_the_size_check_too`.
Stop Action (or any rollback) put a group's edits back but left the steps recorded while the
group was open in the recording, so playing the recording back made edits the workbook no longer
had. A group remembers where the recording was when it began, and its rollback cuts the
recording back to there.
- Test: `stop_action_runs_only_on_its_own_and_is_not_recorded`, which kept the rolled-back edit
before.
Found while testing apps/gridcraft-cli/src/main.rs:196-199 (number_text, used by value_text and check.rs:627)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/sheet.rs:~199 (shift_sheet_features, sparklines loop)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`macro.check` is a query, so the debugger's lock let it through, but its dry run runs the macro's
steps, which the lock refuses: the check reported the lock as the failure of a step. It now waits
with "Finish or stop the macro first", as other commands that change the workbook do.
- Test: `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
The dry run's rollback counts as a change of the document and moves its revision on, which front
ends key their caches on, so a `macro.check` that changed nothing (and a shift it tried and took
back) looked like a change. `Saved` keeps the revision and puts it back with the rest.
- Test: the revision in `shifting_makes_room_and_is_checked_again`.
The merge of #2145 took this branch's macro page whole instead of merging it, which left out the
page's newer text from below: the debugging section as #2145 rewrote it (the lock, Stop Action,
parameters, the time limit for each action), the time limit for each debugger action in the
limits, the check after a shift, and moves that rewrite formula references. The page is now the
merge of both, with this branch's own scope line.
Under `shiftDown` or `shiftRight` with conflicts, `macro.check` looked only for the refusals made
before a shift, so it said `ok` for a run the second footprint stops (an anchor that moves with
the shifted data). It now shifts the data inside an undo group, takes the footprint again, rolls
both back and puts the session back, and reports what still stands in the way.
- Test: the `Named` case and a shift that makes room in `shifting_makes_room_and_is_checked_again`.
- The check for writes over data compared the inputs' sheet with the sheet's index when the run
started, while the inputs follow their cells: after a step inserted a sheet in front, a step
writing into its own input on the moved sheet was stopped as writing over data. It now uses the
sheet's index now. Test: the input case in
`inputs_and_the_output_follow_their_cells_when_a_step_moves_them`.
- Docs: the page says `macro.run` checks the macro, its inputs and parameters and the policy
before it runs, and that a formula whose references a move rewrites counts as changed too.
- target-app-parity.md: the summary table and the method say 276 / 292 and 292 unique ids, as
`cargo xtask parity` measures; Last updated and revision history.
- architecture.md: macros are kept as `_gc_macro_<n>_<k>` (CodeRabbit 4366060044).
- macros.md: the intro says the page covers the engine and its commands, without implying
later work.
- A script step that only opened a dialog (a menu-style call that left the workbook unchanged)
is left out of the script run's steps, by the recorder hook's rule; a script run whose steps
the recording couldn't keep is left out and listed in `skipped`, not read from the workbook
as the script stands when recording stops (a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording).
- A script's toggles keep the state they set (`"on": true`), as a top-level Bold does
(a_script_run_records_the_state_its_toggles_set).
- A recorded macro.run keeps the steps it ran, as a script run does, so saving or deleting the
macro before the recording stops changes nothing (a_recorded_macro_run_keeps_the_steps_that_ran).
- The example input's shape and column types are inferred when recording starts, not from the
data the recording changed (the_recorded_input_is_inferred_as_it_was_when_recording_started).
- Recorded text that playback would read as a token stays text: a reference parameter that reads
as one becomes {"$text": …}, and `{{` becomes `{{{{`
(recorded_text_that_reads_as_a_token_plays_back_as_text). The resolver (below #2152) gains
`{"$text": …}`, text passed as it is (text_marked_as_text_is_never_a_token).
- A recording over the macro size limit is cut at the limit and the steps left out are counted
in `dropped` (a_recording_past_the_macro_size_is_cut_and_counted).
- A macro recorded in a workbook opened from a file is approved for the session, since the
person recorded it (a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking).
- macro.record refuses parameters it would ignore: name or description when starting, relative
when stopping, stop: false while recording, others beside cancel: true, and wrong kinds
(record_refuses_parameters_it_would_ignore).
- Undo while recording keeps the undone step, as in Excel; documented
(undo_while_recording_keeps_the_undone_step_as_excel_does).
- Docs: macros.md Recording and Tokens; module comments no longer say scripts are read.
- Macros::estimated_bytes counted names, descriptions and step counts but not step parameters,
so each save, rename or delete (which copies the macro list an undo step shares) could keep a
4 M-character parameter per step unseen by the 128 MB budget. It now counts each macro's JSON
as it is written, without building it.
- This changes the macro storage layer below #2152, the smallest change that makes the budget
hold (answers CodeRabbit thread 4366060037).
- Test: re_saving_a_large_macro_keeps_the_undo_history_within_its_budget.
- macro.save and a stopped recording count the macros kept in a newer format with the readable
ones, so a save never holds more than the next open keeps; the message names the kept count
(kept_macros_count_toward_the_limit_and_keep_their_names, which saves and reopens as xlsx and
JSON).
- The names of kept macros are taken: macro.save and macro.rename refuse them, unique_name and
next_name step around them (same test).
- macro.save no longer replaces by default: `replace` defaults to false, the error names the
existing macro and says to pass `replace: true`, and a `replace` that is not a boolean is
refused (saving_over_a_macro_needs_replace).
- macro.rename runs the whole macro check, size included, so a longer name can't take a macro
past what an xlsx file holds (renaming_checks_the_macro_size).
- Docs: macros.md, the management commands and kept macros.
- The start options are those of `macro.run`, refused as it refuses them.
- The time limit applies to each debugger action; the step and parameter limits to the whole run.
- The lock lists what runs between steps, and says the sheet and selection are put back.
- Stop Action ends the run; the debugger is never a step and never recorded.
- The answers' fields, including `changedCells`, `shifted`, `wroteFiles` and `message`.
- `macro.debug` refuses to run as a step of a script or macro, and is never recorded (the
recording buffer and Record Actions' journal both leave it out); a debugger whose undo group
something else closed locks nothing, and its next action says the run had ended instead of
reporting a keep or a rollback. Tests: `the_debugger_runs_on_its_own_and_is_never_recorded`,
`a_debugger_whose_group_was_closed_elsewhere_locks_nothing`.
- Stop Action is no longer refused under the debugger: it rolls the run back, ends the debugger
and names the macro. Test: `stop_action_stops_a_macro_being_debugged` (was
`stop_action_waits_while_a_macro_is_being_debugged`).
- Between steps the debugged workbook runs only `macro.debug`, queries and an allow-list of
commands that look around (selection, sheet changes, Go To, Find, Copy, zoom, Switch Window,
Close, Stop Action); everything else is refused, saving and view modes included. The run's
active sheet and selection are put back before its next step. Test:
`between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
- Only the top-level `macro.debug` call rolls the run back after a panic; a panic nested in a step
is that step's error. Test: `a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on`.
- `keep` and `breakpointsOnly` must be true or false, breakpoints must be step paths of the macro
(at most 10,000), and the action is checked before breakpoints change. Test:
`debugger_parameters_are_checked_before_anything_changes`.
- Answers: `executed` counts the run's own steps (not the footprint's), `shifted` and `wroteFiles`
are reported, a rollback's message names the files that stay, `changed` lists deleted and
renamed sheets and at most 500 entries with `changedCells`, and a Repeat over whole rows or
columns shows the part in use. Tests: `the_debuggers_answers_count_its_own_steps_and_say_what_stays`,
`the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list`,
`the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them`.
`automate.stopAction` rolls back the undo groups open on the workbook. While a macro was being
debugged it rolled back the debugger's own group and left the debugger set, pointing at a group
that was gone. It is now refused under the debugger's lock with "Finish or stop the macro first",
as edits are; `macro.debug {action: "stop"}` ends the run.
- Test: `stop_action_waits_while_a_macro_is_being_debugged`.
- `ok` in `macro.check` meant only that the dry run hit no error. It now says whether
`macro.run` with the same parameters would go ahead without a question, and `errors` says
why not: a failing step, a Stop step that rolls back, data written over under `ask` or `fail`,
a shift that can't make room (the refusals `make_room` makes, now in `shift_refusal`), or a
run past the step or parameter limit once the dry runs before it count. `macro.check` takes
`onConflict` and `overwrite` as `macro.run` does, reports the rule it used, and refuses the
parameters `macro.run` refuses.
- After a partial footprint, the question under `ask` says where the check stopped and that
overwriting also lets the later steps write over data unchecked.
- The dry runs and the run share the parameter budget as they share the step budget.
- A dry run puts back the Go To list (`edit.goTo` with `record: true`).
- The footprint groups the touch log by sheet once, instead of filtering a million entries once
per sheet.
- Docs: what `ok` means; RAND's sequence moves on after a check, as after any recalculation.
- Tests: `the_check_says_whether_the_run_would_go_ahead`,
`the_question_after_a_partial_footprint_says_what_the_answer_covers`,
`a_dry_run_puts_back_the_go_to_list`, the shared parameter budget, and `ok` under `ask` in
`conflicts_ask_fail_or_overwrite`.
- A run that rolls back after writing a file no longer says "Nothing was changed.": the message
says the workbook was put back and names the step whose file stays (`StepError::wrote_files`,
also after a Stop with rollback).
- `macro.run` refuses an input the macro doesn't have (a misspelt input bound the selection),
an input that isn't reference text, and `onConflict`, `overwrite`, `approve` or `timeLimit` of
the wrong kind, which it used to ignore; `null` still counts as not given.
- A step no longer copies itself, and Repeat and If no longer copy their bodies, on every pass:
a body is copied once per run. A run passes its commands at most 256 MB of parameters, all
steps together, which bounds a run's work on the web build, where there is no clock.
- Every step except an approved file step runs inside the scripts' file sandbox, so a command
that touches a file under a parameter other than `path` can't do so from a macro.
- Docs: moving cells that hold data counts as writing over them.
- Tests: `run_parameters_that_would_be_ignored_are_refused`,
`a_run_passes_a_bounded_amount_of_parameters`, and the rollback message in
`each_step_is_checked_for_writes_over_data`.
Inputs, the output and a Repeat's range were bound once, by sheet index and coordinates. A step
that inserted a sheet in front of an input's sheet sent every later token to the new sheet, and
rows inserted above an input left the tokens on the cells that moved into its place; the run
wrote into the wrong cells and reported success.
- Commands record the structural edits they make as they rewrite references (`Ctx::edits`; while
a macro step runs, `DocState::edits` collects them).
- After each step the machine matches the sheets as the run's guard does and moves the inputs,
the output and the ranges of the Repeats in progress by those edits, with the formula engine's
own adjustment.
- A step that deletes an input's or the output's cells, or their sheet, or changes the size of a
range a Repeat is going over, stops the run, which rolls back.
- Test: `inputs_and_the_output_follow_their_cells_when_a_step_moves_them` (a sheet inserted in
front, rows inserted above, an input deleted, a Repeat's range grown).
A footprint is not proof of what the run will do. RAND gives the run other values than the dry
run before it (the recalculation's random sequence goes on), and NOW moves on, so an If can take
another branch in the run and write over data the dry run never reached; under `fail` the run
went ahead and wrote over it.
- Unless the rule is `overwrite`, every run checks each step as it goes, as a run after a
partial footprint already did; a write the footprint didn't see stops the run, which asks under
`ask` and fails otherwise, and rolls back.
- Test: `a_run_that_takes_another_path_than_its_check_is_still_checked` (an If on `=RAND()` over
40 runs under `fail`; before, one of them wrote over B1).
Split from the pull request below at the review's request; the code is the runner's footprint
as reviewed there, unchanged.
- `macro.run` first runs the macro in dry-run mode on the real workbook (unless the rule is
`overwrite`) and puts everything back: the workbook, its selection and history through the
dry run's undo group, and the session state a dry run may touch (`Saved`: the active
workbook, open documents, the saved mark, selections, unlocked ranges, trust, clipboard,
preferences, mode, journal and recording, queued requests, format painter, find, ruler,
hidden windows, drawing tool and the comment panes).
- Cells it would write outside its inputs that hold data, and sheets and objects it would
delete, are conflicts: `ask` asks before anything runs, `fail` fails, and `shiftDown` and
`shiftRight` insert cells over the block of every written cell and check again. A shift is
refused when it would move an input, when the macro deletes sheets or objects, or when the
touch log is full.
- A dry run stops before a file step and before Solver; the footprint is then partial, and the
run checks each step as it goes.
- The dry runs and the run share one clock, one time limit and one step budget.
- `macro.check` reports the inputs, the output anchor, approval needed and the footprint.
- Tests: the footprint tests that moved with this code.
The review asked for the runner to be split. This pull request keeps the policy, the input
checks, the step machine and `macro.run`; working out before a run where a macro writes (the
footprint dry run, shifting data to make room, `macro.check`) moves to the pull request above it.
- Unless the conflict rule is `overwrite`, every run now checks each step as it runs, which the
run did before only after a partial footprint: a step that writes over cells outside the inputs
that held data when the run started, or deletes sheets or objects that were there, stops the
run, which asks (`ask`) or fails, and the run rolls back.
- `shiftDown` and `shiftRight` need the footprint, so here they stop the run as `fail` does.
- Moved: `Footprint`, `Saved`, `footprint`, `diff`, `make_room`, `macro.check`, the dry-run
branches of the machine, `policy::previewable`, `DocState::touched` and the touch log,
`Session::journal_dropped` and `truncate_journal`, and the tests of those.
- Test: `each_step_is_checked_for_writes_over_data` (the review's counterexample: a permitted
file step or Solver, then a write over B1, under `fail` and `ask`).
Main adds UiRequest::InsertName (Paste Name, Use in Formula), which puts a name in the cell
editor; a macro step drops it, as it drops a request to start editing a cell.
The recorder expanded an automate.runScript step from the workbook as it
stood when the recording stopped, so a script edited or deleted during the
recording changed the saved macro, or dropped the step. The run now leaves
the steps it ran with the recorder (Recorder::ran, by step index, within
the byte budget), and the conversion reads those, falling back to the
workbook only when they could not be kept.
* Go To: names match in any case and script, as formulas match them
* Paste Name: only the names in view are laid out
* Docs: LAMBDA recursion limit and called error names; one Page Layout view row with the side margin marked unverified
* Go To and Paste Name: cargo fmt
Every path that adds a macro runs macros::check, and an xlsx save of a
macro that skipped it fails (the stash refuses the oversized item). A
JSON save wrote such a macro instead, and the next open dropped it with
a warning. json_bytes now checks each macro first, so the save fails in
either format with the check's message and the macro stays in the open
workbook. Tests that build hostile JSON files write them directly.
stash::items capped the items at MAX_ITEMS before reading them, so one
over the size limit took a place (a readable item past the cap was then
left out with fewer than MAX_ITEMS listed) and one past the cap was
counted in `more` though it could never be listed. Items are now read
first: the first MAX_ITEMS readable ones are listed and only readable
ones past them are counted.
The stash reads a legacy _sc_<key> name as data whether or not it is
hidden, so writing key macro_0 removed a person's visible _sc_macro_0
name with no word. The index allocator steps around that key as well;
this test fails without it.
One file with a visible name of the storage shape, a macro in a newer
format and one whose input declaration breaks the rules, saved and
reopened xlsx then JSON and JSON then xlsx: the visible name stays, the
newer macro comes back whole and numbered around the name, the broken
one is left out once, and the readable one survives.
A macro whose format version is above this build's was dropped on open,
so the next save removed it from the file, and the warning said only
that it couldn't be opened. It is now kept as the file held it
(Workbook::macros_unread, shared between snapshots), never read, listed
or run, and written back unchanged by JSON and xlsx saves, numbered
after the readable macros and around visible names of the storage
shape. Kept macros count toward the 1,000-macro limit after the
readable ones; one too large for an xlsx item is left out. The warning
says it is kept and saved back; damaged and over-limit macros keep
their own warning.
check_limits capped the name, description, notes, step count, depth and
input names, and left the shortcut, permissions, output anchor, default
range, column types, command ids, Repeat ranges, If cells and values and
Stop messages bounded only by the item size; the input declaration was
stored unchecked (rows [5, 2], a cell input with cols [2, 5]).
Now each of them has a limit (docs/macros.md lists them), one-line
fields refuse control characters, and a command id is letters, digits,
. and _ (every registered id passes, tested). An If value may be null
(Empty and NotEmpty ignore it); Repeat.max stays the runner's to bound.
An input's least rows and columns can't pass its most, a cell input is
one row and one column with no header and at most one column type, and
no input lists more column types than columns. check_limits takes every
struct apart field by field, so a new field doesn't compile there until
its limit is decided. A macro from a file that breaks a rule is left
out with the usual warning, never changed to fit.
The JSON reader replaces a workbook with an empty sheet list by a new
one before macros::load reads the file's macro entries, so they were
lost with no warning. The entries are carried across the reset. The
reset itself, which also starts the names and styles afresh, is
unchanged.
with_names numbered macros 0..N, and stash::set_many refuses to write a
hidden name beside a visible one of the same name, so a visible
_gc_macro_0_0 (from a file, or defined after the file opened, when the
stored names are no longer in the workbook) made every xlsx save of a
workbook with macros fail. The index is GridCraft's choice: macros now
take the free indices from 0 up, skipping every key a visible
workbook-scope name of the storage shape uses (compared as the stash
reads names), in their order. No arithmetic depends on a number from
the file.
* UI: theme tokens for icon, reference and sheet-control colours; WCAG contrast test
Tokens gains the colours the chrome was hard-coding, each with a light
and a dark value: on_accent (text on an accent fill), an eight-colour
category palette (theme::Cat), the paper colour inside icons, reference
colours for the formula bar and for the sheet, the message box warning
disc, the tab bar scroll thumb, and sheet_control_* colours that follow
the sheet and so stay light in both themes.
What now comes from the tokens:
- icons: paper (white pages glared in dark mode) and the accent
constants; icons placed on the sheet use icons::paint_on_sheet, which
keeps the light colours;
- reference colours (editor::REF_COLORS), which were hard to read on the
dark formula field;
- white text on the accent in OK, Solve, slicer tiles and the AutoSave
switch, 2:1 on the dark accent;
- the comments pane (a white card with light text in dark mode), gallery
hover and swatch outlines, the message box disc, the tab bar thumb,
chart selection frames, and the sheet's filter and validation buttons.
A unit test (theme::contrast_tests) checks every token pair used by the
ribbon, popups, dialogs, panes, formula bar, status bar and sheet in
both themes against WCAG 2: 4.5:1 for text and 3:1 for glyphs. It caught
the dark danger red (3.5:1 on hover) and the selected header text (4.1:1
light), which are adjusted.
* UI: ribbon scroll arrows, large split button for Formulas › AutoSum
- widgets::big_split_button: the icon runs the default action, the
label and its arrow open the menu. It returns both responses, with
accessible names ("AutoSum" and "AutoSum options"). big_button shares
its label layout.
- Formulas › AutoSum uses it: the icon inserts SUM as before, and the
arrow, which used to do the same, opens the Sum / Average / Count /
Max / Min / More Functions menu that Home › AutoSum already had
(shared as autosum_menu). Its keytip is unchanged.
- When a tab's groups are wider than the window, arrows at the card's
edges scroll them; before, only the mouse wheel did and nothing showed
that the tab went on. ribbon::RibbonOverflow records the groups' and
the card's sizes each frame.
- A test checks every tab fits the card's three rows. It found View ›
Show stacking four checkboxes, with Headings cut off; Show is now two
columns (Ruler, Gridlines / Formula Bar, Headings).
Tests: tests/ribbon_layout.rs (the split button's two parts, AutoSum's
icon and menu, the scroll arrows, the card height).
* UI: formula bar grows from one to five lines; code editor widget
The formula bar is a top-aligned row: the Name Box and the cancel,
enter and fx buttons stay on the first line while the field grows. Its
height is the content's laid-out line count (Alt+Enter or wrapped text)
times the font's row height, from one line (30 px, as before) to five,
then the field scrolls with the caret in view. This holds while editing
and while viewing a cell. The height eases over 120 ms when the line
count changes, and the chevron pins it at five lines (it used to give a
fixed 72 px). The in-cell editor likewise stops at five lines and
scrolls. formula_bar::BarLayout records where the parts went, for tests.
widgets::code_editor(ui, id, &mut text, Lang, max_lines) is a small
code editor for features that edit code: the monospace font, syntax
colours from Tokens::syntax (new, with light and dark values in the
contrast test) for Python (keywords, strings including triple-quoted
ones, numbers, comments and builtins) and LaTeX (commands, scripts and
comments), Tab inserting four spaces (or indenting every selected line,
Shift+Tab dedenting), Enter keeping the line's indentation (one level
more after a Python colon), and a scroll area that follows the caret.
The tokenizer and the edits are pure functions with tests, including
hostile input that must keep spans on char boundaries.
kittest (tests/formula_bar.rs) covers Alt+Enter up to eight lines with
the Name Box and buttons fixed and the caret visible, the commit,
viewing the committed cell, the chevron, the ease, the in-cell cap and
the code editor's Tab and Enter.
* UI: Shift+Tab in the code editor dedents instead of indenting
The editor consumed plain Tab before Shift+Tab, and egui's key match ignores Shift
when the pattern lacks it, so a Shift+Tab was taken as Tab and indented the line.
Shift+Tab is now consumed first; a plain Tab still inserts spaces.
* UI: the ribbon card stays inside a narrow window, so its right scroll arrow is on screen
The row above the ribbon can be wider than a narrow window, and the card took that width, so the
right scroll arrow was drawn past the window's edge and couldn't be clicked: below about 640 px on
macOS, and at 700 px with Linux's wider default fonts, where CI's ribbon test failed. The card is
now kept within the window with the same margin it has on the left.
* UI: each ribbon tab keeps a scroll offset of its own
The ribbon's scroll area used one identity for every tab, so scrolling an overflowing tab and
switching to another overflowing one hid that tab's first groups. Each tab now has its own scroll
state: a tab opened after another was scrolled starts at its left edge, and a tab keeps its offset
while others are used.
* UI: the in-cell editor keeps its whole width for the text and shows every line
Merged with main, the grid's own test asked the in-cell editor to wrap its
text at the field's full width and to show every line (the editor never
scrolls); the editor widget wrapped it inside the formula bar's scroll area,
which took the bar's width off the text (168 px tall where 156 was right),
and the grid capped it at five lines. The in-cell editor now shows the text
field on its own, through the one show_edit helper the formula bar also
uses, and grows with every line; the formula bar keeps its five lines and
its caret scroll. The branch's five-line test for the cell editor becomes a
grows-with-every-line test.
* Docs: the UI building blocks' revision rows carry the date they land
The rows said 2026-10-11, older than upstream rows above them, and the
ui-parity row said the in-cell editor is capped at five lines.
* UI: the formula bar's view of a cell starts at the cell's top
The bar's read-only view kept one scroll offset for every cell, so a cell
shown after scrolling another's text started scrolled. The offset is kept
per cell now.
* UI: the editor's field does nothing with Enter on its own
The editor handles Enter (commit) and Alt+Enter (a line break) itself, and a
message box over the editor takes Enter to close. The field under it is a
multi-line TextEdit since the bar grew, whose own return key would add a
line if it ever saw Enter; return_key(None) keeps that from depending on
where focus sits. A test shows Enter over a message box adds nothing to the
entry (focus is off the field then, so it passed before too: this is
hardening, not a repair). The doc comment split by the show_edit helper is
mended.
* Docs: ui-parity says the in-cell editor shows every line, and its revision table is whole
A sentence still said the in-cell editor stops at five lines, and a blank
line between the table's separator and its first row ended the table.
* UI: the split button's tooltip carries the shortcut hint like every ribbon button's
big_split_button built its tooltip with the bare translation, so AutoSum,
which became a split button, lost its shortcut hint. It uses tip_text now;
the split-button test hovers the button and looks for the hint.
* UI: a selection that ends at the start of a line leaves that line alone
Selecting "a\n" in "a\nb" and pressing Tab also indented b, and Shift+Tab
could take its indentation: the line after the selection was counted as
touched. It is touched only when the selection reaches into it.
* UI: the code editor applies its keys in input order, with the typing around them
The editor consumed Enter, Tab and Shift+Tab before the text field saw the
frame's events, so a frame carrying typed text followed by Enter (events
gather between repaints, and under key repeat) put the newline before the
text, and two Enters in one frame made one line. The editor now walks the
frame's events in order and applies typed text, pastes, Enter, Tab,
Shift+Tab, Backspace and Delete itself, taking them from the queue; the
first event it does not handle and everything after it stay for the field,
which runs next, so the order holds either way.
* UI: the code editor keeps the input order past the keys the text field handles
The editor applied its keys up to the first event it did not handle and
left that event and everything after it to the text field, which has
its own Tab and Enter: select all followed by Tab in one frame replaced
the code with a tab character, and since the platform sends a key event
before each text event, an Enter after ordinary typing lost its
indentation.
The editor now applies its keys up to the first event the field acts on
(a caret or selection key, a key under a command modifier, IME, a click);
that event and the run after it stay for the field; from the editor's
next key on, the keyboard events are held back under the editor's id for
its next pass, where it puts them ahead of that pass's input and splits
again. They go only to the editor they were typed into, only while it
still has the focus, and only in the pass right after; otherwise they
are dropped rather than typed into another widget. Printable key presses
do not split the stream. Tab under a command or option modifier is taken
off the queue, and Enter under Shift or Option is the editor's newline,
so the field never gets a Tab or an Enter of a focused editor.
* UI: the ribbon layout and formula bar tests join the crate's integration test binary
Since #2372 the crate's integration tests are one binary built from tests/it/, one module per file; only tests that set process-wide state stay separate. These two set none.
* UI: the formula bar's one-line height follows the font, so five lines fit on Windows
One line was a fixed 30 px and the bar grew by one row height per line.
The panel's and the field's margins take 12 px of that, and rounding to
the pixel grid up to another, so a row taller than 17 px lost the last
counted line: with the Windows UI font (17.28 px rows) five lines showed
four, and bar_grows_to_five_lines_then_scrolls_with_name_box_pinned
failed there.
formula_bar::one_line(row_h) is 30 px unless the rows need more, and
every counted line fits whatever the font. The kittest heights are
computed from it rather than from 30, and a unit test checks the
arithmetic for rows of 15.6 to 20 px.
* UI: Paste Special's OK button uses the on-accent colour
It was the one OK button still drawn white on the accent: 2.14:1 in the dark theme, where every other OK uses on_accent (7.1:1).
The Column or Bar menu on the Insert tab listed every column and bar
type of the All Charts list except 100% Stacked Bar. It now lists it
after Stacked Bar, and choosing it runs insert.chart with type bar and
subtype stacked100.
Insert > Line or Area and the All Charts list offer 100% Stacked Area,
and insert.chart and chart.set take it as type area with subtype
stacked100. Each series' area stacks on the ones before it as its
share of the category's total. When every series on the value axis is
100% stacked and no value is negative, the axis runs from 0% to 100%.
A blank cell counts as zero, and a category whose total is zero stays
on the baseline.
An XLSX area chart with percentStacked grouping now opens as 100%
stacked and is saved back as percentStacked. It used to open as a
stacked area and was saved as stacked.
DocState::new recalculates the whole workbook, so values saved in the file
change where nothing was edited (volatile functions, functions GridCraft
lacks, cycles). DocState::from_saved builds the dependency graph without
evaluating, so every value stays the file's until an edit reaches it.
Writing a note or comment on a cell that already has one of the same kind
replaced the whole thread: Edit Comment lost the replies, the resolved state
and the dates, and Edit Note the box read from the file. Now only the text
(and the author, when the command names one) changes, as Excel's Edit Note
does; a note written over a comment still replaces it.
The writer numbers sheetIds from 1 in sheet order, so a rewritten workbook
changes the id of any sheet that is not part of a gapless series from 1.
Anything that recognises a sheet by its id (parts carried through as they
are, external tools) loses it. The model keeps the sheetId it read
(Sheet::file_id, PreservedSheet::sheet_id, not serialised to JSON) and the
writer writes it back; a new sheet, or a copy with a taken id, gets the first
free one.
The submenu lists up to 100 cells. When the list was taller than the
window, the cells below its bottom edge could not be reached. The list
now sits in a scroll area 400 points high.
The status bar named the first circular reference in the list, even
when it was on another sheet. It now names the first one on the active
sheet, and shows only Circular References when every loop is on other
sheets, as Excel's status bar does.
formulas.errorChecking listed each circular reference as Sheet!A1 with
the sheet name as it is, so a sheet named Bob's Q1 gave Bob's Q1!B2,
which a formula does not accept as a reference. The list now quotes the
name with quote_sheet, as references are written elsewhere in the
engine ('Bob''s Q1'!B2), and the status bar's Circular References
label uses the same form.
Formulas > Error Checking is now a split button. The button opens the
Error Checking dialog as before, and its arrow opens a menu with Error
Checking… and Circular References. Circular References lists a cell on
each circular reference as Sheet!A1, up to 100, and picking one selects
it on its own sheet and scrolls the grid to it. With no circular
reference the item is unavailable.
The calculation kept only the loops its latest run found, so typing in
any cell outside a loop emptied the list, and the status bar's Circular
References with it, while the loop was still there. A partial
recalculation now keeps the loops among the formulas it does not
evaluate. In automatic calculation a loop leaves the list once an edit
breaks it. In manual calculation only the edited formulas run, so a
listed cell stays until it is edited or the workbook is recalculated.
A full recalculation or iterative calculation builds the list again.
Fields still holding unconfirmed text when the checkbox turns iteration off are no longer committed,
and Maximum Change parses with the interface's decimal separator as well as a dot.
The Header & Footer tab now has the Scale with Document and Align with
Page Margins check boxes, and pageLayout.headerFooter takes
scaleWithDoc and alignWithMargins. XLSX already kept both settings,
but nothing else read them.
With Scale with Document on, header and footer text prints at the
sheet's print scale. With it off, the text keeps its point size. With
Align with Page Margins on, the left and right sections line up with
the page's side margins. With it off, they start 0.7 inch from the
paper's edges, or a quarter of the page width on paper narrower than
2.8 inches. The Page Layout view draws headers and footers and takes
clicks on them the same way. Both settings are on by default, so a
sheet printed at a scale other than 100% now prints its header and
footer text at that scale.
The Page Layout view row in docs/ui-parity.md listed both options as
missing. The Header & Footer tab has had both check boxes since #344, and
the first page and the even pages print and show their own header and
footer. The row now says so and lists Scale with Document and Align with
Page Margins as missing instead: XLSX keeps both settings, but no control
shows them and printing does not read them.
Data > Outline > Settings now has Summary rows below detail and Summary
columns to right of detail. With one cleared, each group's - / + button
sits on the line before the group, its bracket runs down to the last
detail line, and Hide Detail, Show Detail, the level buttons and
Ungroup mark or clear the collapsed flag on that line. Changing a
direction moves the flags of collapsed groups to their new summary
lines.
- model: Sheet::summary_below and summary_right (on by default, also
for older JSON).
- xlsx: read and written as sheetPr/outlinePr summaryBelow and
summaryRight; files that set them no longer lose them on save.
- engine: data.outlineSettings {sheet?, summaryBelow?, summaryRight?},
undoable; outline::groups_with places the summary line.
- ui: a Settings button in the Data tab's Outline group opens the
dialog; the outline band follows the setting.
The cell menu checked whether to offer Show Details by computing the
whole report again for each new active cell. pivot::detail_cells now
returns every value, subtotal and grand total cell of a report from one
computation, and the menu caches that set per document, revision, sheet
and PivotTable.
With Enable show details off, Show Details on a value cell of a report whose
source has changed gave the out-of-date error. It now gives the turned-off
message, and a label still reports that it is not a value cell. The message
names pivot.options with enableShowDetails set to true, since there is no
PivotTable Options dialog.
The sheet tabs took up to 60% of the tab bar and the horizontal
scrollbar the rest, and nothing between them could be dragged. The
gap at the scrollbar's left edge is now a handle: it shows the resize
cursor, and dragging it right gives the tabs more of the bar, until the
scrollbar is 100 px wide. After the window is narrowed, the scrollbar
is still 100 px wide. Dragging it left gives the scrollbar more, down
to the New sheet button. Room the tabs do not fill stays empty, and
New sheet stays after the last tab.
Each open workbook keeps its own share while it is open. The share is
not saved in the file, and a workbook starts at the default.
Found while testing crates/formula/src/parser.rs (reference/sheet-prefix parsing) with crates/formula/src/printer.rs
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The cell menu of a PivotTable's value, subtotal or grand total cell now
has a Show Details item after Sort Z to A. It runs pivot.showDetails on
the active cell, which lists the cell's source records on a new sheet,
as double-clicking the cell does. Labels and headers of a current
report, and cells outside a report, do not offer it. In a report that
is out of date every cell of the report offers the item, and running it
shows the message that asks for a refresh.
Whether the menu offers the item is cached per document, revision,
sheet and active cell.
Found while testing crates/functions/src/date.rs:443-457 (yearfrac_core, basis 0)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A PivotTable has an Enable show details option, set with the new
pivot.options command ({pivot?, enableShowDetails?: bool}). While it is
off, pivot.showDetails and double-clicking a value cell list no records:
the double-click shows a message and does not open the cell for editing.
Labels and headers still edit. Changing the option is one undo step and
does not refresh the report. pivot.fields reports the setting.
XLSX files keep it: an off option is written as enableDrill="0" on the
pivotTableDefinition, and enableDrill is read on open (on when absent).
Found while testing crates/functions/src/engineering.rs:713 (imsqrt, via Cx::powf at :562)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
When a fill starts from a date, the right-drag fill menu and the Auto
Fill Options button now also offer Fill Days, Fill Weekdays, Fill Months
and Fill Years. edit.autoFill takes them as the modes days, weekdays,
months and years.
A lone date steps by one unit. Two or more dates step by the difference
between the first two in that unit, or by one unit when both fall in
the same one. Fill Weekdays skips Saturday and Sunday, and a start on a
weekend moves to the next weekday first. Fill Months and Fill Years keep
the day of the month, clamped to the month's last day. A source that is
not all dates fills as a plain drag does.
Fills by months or years now keep the time of day of a date with a
time. A plain drag of dates a month apart and Series by Month or Year
used to drop it.
Found while testing crates/functions/src/date.rs:123-129 (isoweek)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#1877 added Chrome to SheetApp for hosts that embed the sheet natively, but the web build never set it: an iframe host that owns the files still draws the title bar it duplicates, and Backstage's Open/Save As can replace the document behind the host's back. Parse the flag where the other URL flags are parsed; the UI already honours Chrome, so nothing else changes.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Agile decryption checked only the password verifier, which authenticates
the key — not the package. An attacker who could replace EncryptedPackage
ciphertext could change workbook content undetected ([MS-OFFCRYPTO]
2.3.4.14, the descriptor's dataIntegrity element).
The reader now requires the dataIntegrity pair, decrypts it with the
recovered package key, and checks HMAC(hash, hmacKey) over the whole
EncryptedPackage stream (StreamSize field included) against the stored
value before any plaintext is returned. Excel always writes the element
("SHOULD" in the spec); descriptors without it are refused as damaged
rather than accepted unauthenticated.
- crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key
longer than the block size
- fixtures write the pair like Excel; new tests: missing/empty/ragged
integrity fields, tampered ciphertext and descriptor, a changed
StreamSize field
save_fails_upfront_when_the_temp_name_is_taken,
save_keeps_the_destination_access_restrictions and
open_refuses_directories_and_unknowable_lengths in crates/engine/src/io.rs
all worked in temp_dir()/gridcraft-fs-safety and removed it at the start
and the end. cargo test runs them on parallel threads, so one test could
delete the folder while another was writing to it, and the other test
failed. Each test now uses its own folder, named with a short label and
the process id, as the gridcraft-write and gridcraft-sandbox tests in the
same file do.
Ctrl+Shift+U ran the Ctrl+U shortcut on Windows and Linux and underlined
the selection. In Excel it expands or collapses the formula bar, on
Windows and as Control+Shift+U on a Mac. It now does that on the grid
and in the cell editor or formula bar while editing, and the entry stays
open.
The new UI command view.expandFormulaBar {on?} sets the state, or
toggles it without a parameter, and returns {on}. The chevron at the end
of the formula bar is unchanged.
- floating_pictures: draw without retaining when eviction can't free
enough room, so a large workbook can't pin the cache past its budget
- xtask stats: skip unreadable subtrees instead of aborting the walk
- import-cert.sh: per-run private temp dir instead of predictable
paths in a shared root
- gridcraft-web: gate clipboard ops behind user activation
- chart raster: cap dash rendering to the stroked budget
- cli check: mark directories visited by identity on unix so hard-link
loops can't recurse; reuse one decompression of a reused part
- scripts: refuse `window.*` steps like file and link steps — activating
another window repointed every later step at a different open document
(finding 17).
- format: on a protected sheet, formatting may not change a cell's
protection flags: the format-cells permission can't drop `locked` and
then authorize content edits the password gates (finding 20).
- sheet.moveColumns: the insert/delete protection checks now carry the
command's own `sheet` parameter, so the sheet modified is the sheet
authorized, not whichever is active (finding 24).
Tests: unlock-via-format refused, cross-sheet move refused, window switch
refused, plus the positive controls. Engine: 462 tests pass; clippy clean.
- read_file: verify the opened file is regular and cap the bytes actually
taken at the 2 GB limit — a workbook-named symlink to an endless device
reported length 0 but streamed forever (finding 22).
- write_file: create the temporary file exclusively, so a planted file or
symlink at the predictable `book.xlsx.sctmp` path makes the save fail
instead of truncating the link's target (finding 25).
- write_file: inherit the destination's permissions, so saving an
owner-only workbook doesn't become world-readable through the rename
(finding 15); new files note restrictive defaults.
Tests: exclusive temp creation vs planted symlink, permissions preserved,
directory refused on open. Engine: 460 tests pass; clippy clean.
- clip_html: cap wildcard/glob lookback to a 12-byte window instead of scanning
outgoing text arbitrarily far for a semicolon (finding 18).
- print: wrap_lines binary-searches its break instead of rescanning the line
from the start for every band (finding 21).
- autofit: per-row merge index instead of scanning every merge per row during
autofit (finding 27) — behaviour covered by the existing autofit merge test.
- Save as Web Page: the used rectangle bounds the HTML (finding 28, #2387) —
a tall/wide or sparse far-right sheet now errors instead of growing
gigabytes of <td></td>.
- print: decode each embedded picture once per document instead of once per
band that shows it (finding 30).
Engine: 459 tests pass; clippy clean.
Security-sweep findings from the scan at revision ac8da7d4 (storytold-
gridcraft-agent-findings.json), no longer exploitable on main at 72c1d57d.
Each change caps work or storage per sheet/part so a hostile file cannot buy
unbounded work with ordinary bytes; each has a regression test:
- finding 2 (package.rs): a part rejected by the MAX_PART size gate is
charged its declared compressed size up front, so a loop of oversized
parts still ends the allowance instead of re-entering the package.
- finding 4 (xml.rs): elements past the kept-node cap are consumed and
dropped instead of kept — parse cost stays linear, memory stops growing
with the same input (a small compressed workbook previously asked for
gigabytes of declared-sized metadata tree).
- finding 10 (records.rs): .remaining() caches the suffix of a shared
string's Continue parts, so crossing N Continue records is O(N) instead
of O(N^2) in records scanned per character.
- finding 9 (xls shared formulas): the SHRFMLA master template is parsed
once and re-emitted per dependent cell instead of decoded and reprinted
for every referencing cell.
- findings 3, 6, 8 (sheet_read.rs): hyperlink-cell expansion, conditional-
format kept range cells and dynamic-array (deleted-spill) checks are
capped with one import warning each instead of the compact-workbook
multi-gigabyte allocation or the scanned-per-cell cost.
- finding 31 (core/parse.rs): datetime recognition no longer reparses a
prefix at every space with a time-shaped tail; an all-digit field length
is gated before the derivation machinery runs, staying linear in the
text.
🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
The status bar always showed Average, Count and Sum for a selection of
several cells and had no menu. A right-click on it now opens a menu with
Average, Count, Numerical Count, Minimum, Maximum and Sum, as in Excel.
Average, Count and Sum are on by default; the other three are off.
The text on the status bar can no longer be selected, so a right-click
on the mode text or a statistic opens the menu as well. The zoom and
view controls handle their own clicks as before.
The choice is a preference set with the new view.statusBar command and
kept in prefs.json. A prefs.json without it keeps the defaults.
Found while testing crates/functions/src/stat.rs:257-260 (trunc_digits, used by percentrank_impl)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sweep findings from the ac8da7d4 security scan (storytold-gridcraft-agent-
findings.json), remaining on main at 72c1d57d:
- finding 14 (crates/calc/src/eval.rs:988): every lambda invocation now
spends one charge against a per-cell work budget (16M — one per cell of
the largest array a reference may expand to), so a hostile, deeply
nested LAMBDA/LET formula stops at the bound a legitimately wide one
could reach instead of growing exponentially with nesting. Exhaustion
is #CALC!, like the existing depth cap. Metered at `call_lambda_values`
so every path into a body is counted (direct application, MAP/REDUCE/
SCAN/BYROW/BYCOL/MAKEARRAY, D-function criteria); the criteria
sub-evaluator hands its spend back to the parent.
- finding 16 (crates/functions/src/criteria.rs:313): `match_from` takes a
step budget (1<<26: an honest match against Excel's 32,767-char cell
ceiling costs a few hundred thousand steps), and `wildcard_find` drops
the leading star run (a no-op for a prefix match) so the first literal
anchor skips straight to its occurrences; all suffix attempts share one
budget. Star churn degrades to no-match instead of quadratic work.
Unit tests: mock-host exhaustion/under-budget for lambda applications
(eval::fuel_tests), nested REDUCE under budget in full recalc, wildcard
behaviour unchanged.
🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
The Go To dialog lists the named ranges of the workbook and of every
sheet, a name scoped to another sheet as Sheet2!Name, without hidden
names, constants or formulas. After them it lists the references
entered in the dialog, newest first, and a double-click on an entry
goes there.
edit.goTo takes several names or references separated by commas
(Price, Type or B14:C22,F19:G30) and selects them all, follows a name
that refers to several areas, and goes to Sheet2!Name on Sheet2. With
record: true it keeps a reference that holds no name among the recent
ones, and with list: true it returns the names and the recent
references.
formulas.pasteName lists the names the active sheet can use (workbook
names and the sheet's own, without hidden names, in alphabetical order).
With name it puts the name at the caret of the formula being edited,
replacing selected text, or starts =Name; with list: true it writes each
name and the text of what it refers to in two columns from the active
cell. The Paste Name dialog opens from F3 when the workbook has names,
from Use in Formula's new Paste Names… item and from Alt+M, S, P. While
a dialog is open over the edit, the editor takes no keys.
Use in Formula lists the same names, and formulas.useInFormula inserts
at the caret instead of replacing the text being edited.
The recorder left out every call that opened a dialog, so a script with an
edit before a menu-style step lost that edit from the recording. A call is
left out only when it opened a dialog and changed nothing (the dialog's
confirming call is the step); one that changed the workbook is kept.
Run from inside another command, automate.stopAction rolled back that
command's own group, pulling the workbook from under the rest of its run; and
the recording buffer kept it, so a recording could replay the stop inside the
run it was part of. Nested, the command is refused and the group stays; the
recorder leaves it out (record::NOT_RECORDED).
stash::items and stash::keys listed the first 10,000 items and let the rest
vanish without a word. They return a Listing { found, more } now, so a caller
can say what it did not list.
- PMT: take rate / m first so (pv + fv) / m can't overflow for a tiny rate (PMT(1e-300,10,1e12)).
- Keytips probe: an empty params object keys like no params.
- Number Stored as Text check reads the same accounting parentheses as typed entry.
- Lookups: a NaN number falls back to the general comparison instead of comparing equal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Engine: undo groups make several commands one undo step
Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.
While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.
Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
* Engine: a recording buffer of top-level commands, apart from the journal
A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.
Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.
Automate › Record Actions keeps recording through the journal as before.
* Engine: document trust for workbooks opened from files
Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.
The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
* Engine: workbook data in chunked hidden names (cmd/stash.rs)
GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.
cmd/stash.rs stores an item `key` as hidden workbook-scope names
_gc_<key>_0, _gc_<key>_1, ..., each a formula of string literals of at
most 255 characters (UTF-16 units, as Excel counts them) joined with &,
at most 8,192 characters per name. Reading concatenates the chunks in
order and stops at a gap; it also accepts the older single _sc_<key>
name. Names are matched case-insensitively and only at workbook scope.
Keys are limited to letters, digits, _ and ., items to 4,194,304
characters, and junk around or inside the literals, duplicate or
out-of-order chunks, non-JSON and over-deep JSON all read safely.
keys(prefix) lists the stored items that start with a prefix.
Custom views, scripts, the watch list and scenarios keep their current
storage; nothing reads or writes the new names yet.
docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.
* Engine: a linked paste of values from a file carries the file's trust
A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.
* Engine: a stash item with only later chunks reads as missing and falls back to the legacy name
With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists.
* Engine: a command that runs its own undo groups makes one undo step for itself
execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.
* Engine: a full undo history keeps one step per command that runs its own undo groups
When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.
The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.
* Engine: pasting only formats from a file leaves the destination's trust alone
Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.
Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.
* Engine: a command keeps its group's name only for a group it ended itself
The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.
The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.
* Engine: a group rolled back inside a command keeps the redo history
A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.
* Engine: a visible name of the item shape is not workbook data
The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.
* Engine: a command keeps its group's name only for a step that is exactly the group's
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.
The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
* Engine: the recording buffer is bounded in bytes and keeps the start of a recording
The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.
* Docs: the architecture revision row carries the date the change lands; document trust beside script trust
The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).
* Engine: writing a stash item checks for a clashing visible name in one pass over the names
The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.
* Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.
A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.
The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
* Engine: the undo steps made inside a command are bounded while it runs
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.
Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
* Engine: a grouped action that closes its document succeeds
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
* Docs: undo groups at a command's boundaries, and the bound on steps made inside a command
* Engine: a linked paste carries the file's trust whatever it pastes
Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.
* Engine: a stash chunk number with leading zeros names someone else's name
`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.
* Engine: a command that panics leaves the undo history as it found it
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
* Engine: a headless call that fails for want of a file is not recorded
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
* Engine: a later stash chunk on its own is not a listed item
stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.
* Tests: a linked paste of column widths or notes copies its metadata and no cell
The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.
* Engine: the panic guard restores entries a nested Undo removed
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
* Engine: a group a panicking command ended keeps its step
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
* Docs: list every paste that carries a file's trust
The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.
* Engine: a grouped action that panics is rolled back before the panic goes on
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
* Engine: the recorder counts a dropped step without serializing it
Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.
* Engine: a failed command rolls back the undo groups it opened
A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.
`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.
`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.
* Engine: the recorder skips a call that only opened a dialog
A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.
`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.
* Engine: stash items keep characters XML can't carry
The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.
`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.
* Engine: list stash items in one pass over the names
Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.
`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.
* Engine: the name commands leave GridCraft's data names alone
`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.
Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.
* Engine: queries take no copy of the undo history
`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.
The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.
* Engine: a document's maker says where its content came from
`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.
`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.
* Engine: the name commands refuse only names that hold GridCraft's data
The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.
* UI: the toolbar's Undo and Redo follow the commands' own availability
The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.
* Engine: the name commands use the stash's own rule for what holds its data
The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.
* Docs: the panic guard's note says what happens to a group the command ended
It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.
The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.
The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.
`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.
`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.
`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.
The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.
`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.
Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.
Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.
`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.
The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.
`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.
A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.
`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.
A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.
`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.
`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.
automate.allScripts reports trusted since #2178, and scripts reopened from
a file are not trusted yet, so the side-by-side test expects the flag and
compares names and steps after the round trip. cargo xtask parity gives
276 / 292 now (upstream added a command), so the ROADMAP and ui-parity
summaries say so, and this branch's revision rows (275 / 291 to 276 / 292)
sit at the top of their tables with the date they land.
Six fixes from a review of the recorder, each with a regression test (four
fail against the old recorder; the other two bound work that grew with the
input):
- A step that names another sheet with its sheet parameter (cell.set and
about fifty other commands) was tokenized as if on the input's sheet, so a
recorded cell.set {sheet: Two, cell: C4} ran on Two at the input's
relative place. Such a step keeps its references, like a sheet-qualified
one.
- Inlining recorded runs of other macros and scripts stops at the macro's
step limit and counts the rest in dropped, instead of expanding every run
first (a thousand runs of a long macro made millions of steps).
- macro.record {cancel: true} ends a recording without saving it: the way
out of a recording whose macro is too large for the workbook, which could
neither be saved nor ended.
- Formula tokenization reads each reference in the formula and matches it,
instead of listing every column and row of the example (an example as
large as a sheet made a million candidates per formula).
- A selection of several areas keeps its active cell as it is; a token for
the active cell alone put it outside the areas on playback.
- Steps recorded in another workbook are counted in dropped.
The touch log keeps at most a million writes; past that, a write that kept a
cell's value is missing from the footprint, so a shift derived from the
block of written cells could split a row the macro writes. The footprint
now says when the log filled up (logFull in macro.check), and a shift is
refused then, as it is when the macro deletes sheets or objects. Regression
test with a million-cell fill in a dry run.
Every nested command a dry run executes is journaled, so a long macro pushes
the journal past its limit and the journal drops its oldest entries. The
restore cut the journal back to its old length, which then kept entries from
the dry run and pointed Record Actions at the wrong place. The session now
counts the entries its journal has dropped (journal_dropped) and truncates
with its size bookkeeping (truncate_journal); the restore keeps the old
length less what was dropped meanwhile and moves the recording's start back
by the same count. Regression test with 12,000 nested steps.
file.close is not an edit, so the debugger's lock let it through, and the
session kept the debugger with a token for a group that went with the
document: the next workbook could not start a debugger, and the state
reported that no document was open. close_document (also the path that
replaces an untouched blank workbook on file.open) now ends a debugger whose
workbook it closes. Regression test.
Four fixes from a review of the runner, each with a regression test that
fails without it:
- A shift inserts cells over the block of every written cell, which moves
that block's columns (or rows) from there on. When a bound input lay in
the way, its data moved and sheared while the run kept the old coordinates
and reported success. The shift is now refused, naming the input and its
place. The output is not counted: it is where the macro writes, and the
second footprint catches data a shift moved into it.
- A dry run or a refused run left a clean workbook dirty: view.zoom,
sheet.activate and the view modes move the saved mark to the current
workbook on a clean document, and the rollback put the workbook back but
not the mark. Saved keeps and restores it.
- overwrite: true answers the question however ask was given; with an
explicit onConflict: ask the answer was ignored and the question came back
forever.
- The dry runs and the run share one step budget as they share one clock;
each phase counted its own 100,000 steps before. ran reports the run's
own steps.
Input.col("date").data resolved to the whole column, header included, because
every method cleared the header state. col and cols keep it; the other
methods still clear it, and only data keeps the header row for col("…").
The token test asks for D3:D11, cols(2..3).data and col(2).data.col(1).
Two items under one key, in any case, would leave two hidden names of one
name, of which an xlsx file keeps one, and get would mix their chunks. A
caller's mistake, refused before anything changes. Regression test.
Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.
builtin_id already wrote the currency formats 5-8 as custom formats
because readers resolve those ids by locale. The accounting formats
with a currency, 42 and 44, are resolved the same way but were written
as bare ids, so LibreOffice showed a 44 cell holding -0.14 as "- 0.14"
where US Excel shows "$ (0.14)". Treat them like 5-8.
Six fixes from a review of the resolver, each with a regression test that
fails without it:
- A bare Row or Column under a general reference key (to, from, at, area,
source, target, destination, location) stays text: Change Spelling's
replacement word and a pivot field's area were being rewritten into the
current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still
resolve there, and the bare word still resolves under cell and range keys.
- {{{{ in a string is a literal {{, so text that needs the braces can be
written; an unknown token is still an error.
- Input.data on a range that is only a header row, and Name: of a table
with no data rows, are refused instead of reading the header.
- below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead
of becoming 1.
- A defined name on a sheet whose name holds a dollar sign resolves: the
dollar anchors are stripped from the reference part only.
- quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which
quotes a sheet named like a cell reference, an R1C1 token or a boolean and
leaves a period unquoted.
docs/macros.md says which keys take a bare token and how to write a literal
brace.
A macro could declare an input called Q1, AB12 or Sum1, and a step written
with plain A1 text, as a recorded one is, would then resolve to the input's
range instead of the cell. check_limits refuses such names; the docs say so.
Regression test.
Found while testing crates/functions/src/text.rs:326-340
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
workbook copy made for a cell edit or an undo snapshot holds the same list,
and the undo budget charges a list once a snapshot owns its own copy.
Before, every mutating command copied every step of every macro, and a
hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
kept on open and on save, as a visible one is, instead of being dropped
without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
file, built in code): the model's limits and the xlsx size limit.
Regression tests for each.
The depth check refused any step list at the fifth level, so a fourth Repeat
or If could not even hold an empty body: three levels nested, not the four
MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If
steps themselves, in the model's limits and in the parser's pass over the
JSON. The test asks for four and refuses a fifth.
The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
The inputs paragraph described how a run binds inputs and the message it
shows, which the runner adds in a later pull request. It now says what the
declaration is for and that this version stores and checks it.
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.
stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.
Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.
Typed entry, VALUE and arithmetic now read $(1,234), $ (5) and (5)% as
negative numbers, as Excel 365 does. Parentheses no longer nest: (-5),
((5)), $5% and dates in parentheses stay text. This changes main's
((100)) from 100 to text; the no-recursion guard from #2180 is kept and
its test now asserts the new result.
The merge from main moved the Japanese catalog into i18n/ja.rs, but the
feature's old table stayed in i18n.rs and was never used, which failed
clippy's dead-code lint. Drop the table and add the four new labels and
messages to ja.rs.
PMT computed (1 + rate)^nper with powf, which rounds 1 + rate first and
drops the low bits of a small rate. Over 400 synthetic probes that left
PMT up to about 1000 ULPs from Excel (59 bit-exact); the ln_1p/exp_m1 form
is within 4 ULPs (244 bit-exact). A loan schedule guarded by
IF(p - SUM(principal) > 0, ...) turns on those bits: in 3000 synthetic
schedules main disagreed with Excel on the number of live periods in 1382,
the fix in 823.
Also follows Excel at the edges: a rate of -1 or less is #NUM!, a huge
nper gives -pv*rate instead of #NUM!, a tiny rate no longer collapses, and
a small negative rate keeps its payment. IPMT, PPMT, CUMIPMT and CUMPRINC
pick this up through pmt_calc. FV and PV are unchanged: Excel's own FV/PV
sit further from the exact value than the current powf form does.
The tests allow the one-ULP libm difference between platforms.
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.
Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.
A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.
The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.
The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).
The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.
The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
- #2369: its test joins the single ui-egui integration-test binary (tests/it), as main now builds them.
- #2367: a cell set to empty text is a blank entry and doesn't grow the AutoFilter (CodeRabbit).
- #2387: MOD's range check also covers the sign-corrected remainder (CodeRabbit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/functions/src/math.rs:305-319
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A string a catalog lacks shows in English. Coverage, key-parity and
per-language count tests and tests of one language's exact wording are
removed; UI wiring tests take their expected text from the catalog.
cargo xtask i18n-report lists the missing strings per language and
always passes; cargo xtask ci runs it first.
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
A number too wide for its column is drawn as ####, with as many # as fit. The count used the
width of 0, and in fonts where # is wider than 0 the hashes ran past the cell edge.
accounting_fill::too_wide_fill_number_shows_hashes_not_a_clipped_value caught it on FreeBSD
and on Linux machines without the CI font set.
* Packaging: pin the Windows signing client and check its hash
* Packaging: pin appimagetool and check its hash
* Packaging: pin the AppImage runtime and stop clearly without a pinned tool
* Packaging: shellcheck-clean empty hashes
* CI: dev builds keep line tables only
* CI: one integration-test binary per crate
* CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm
cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps.
* CI: docs name cargo xtask test
* CI: agent guides name the moved fuzz test
* CI: tests added on main join their crate's test binary
* Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the hand-rolled civil_from_days / days_from_civil copies and the
is_leap / days_in_month calendar helpers with chrono as a workspace dependency
(default-features = false, std only, so wasm is unaffected). The spreadsheet
serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial
60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday
numbering and the 1900/1904 systems keep their explicit special cases in
serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs
the plain proleptic-Gregorian day-number <-> date conversion and the
leap-year / month-length helpers those build on; a small fallback keeps the old
answer for dates outside chrono's NaiveDate range. The duplicate local is_leap
in the financial functions now uses the core one. Output is byte-identical and
pinned by the existing date, parse, financial and logging tests. AGENTS.md now
says time goes through chrono.
Follow-up to storytold/cadcraft#1785
* Never panic on out-of-range clocks or years; keep calendar results outside chrono's range
- logging::timestamp: chrono's From<SystemTime> unwraps, so a clock past year
262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
and read such a clock as the epoch, like the old saturating code. Years past
9999 are written with {:04} (not %Y, which prefixes a '+').
- date::is_leap: restore the Gregorian leap-year rule for years outside chrono's
NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic.
- date::days_in_month: compute the December boundary with checked_add so
days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths
through days_from_civil's fallback so out-of-range years keep their real
lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0.
- civil_from_days: add missing docstring.
- AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule.
* Don't build an unrepresentable SystemTime in the far-future test
UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.
* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies
The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback`
copies for dates outside chrono's ±262143-year range, plus a hand-rolled
Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`.
AGENTS.md carved a "narrow fallback" exception for them, which left calendar
arithmetic outside chrono after all.
Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce
the year with `rem_euclid(400)` (leap years and month lengths repeat every 400
years), which also removes the December `i32::MAX` overflow special-case.
chrono is now the only calendar logic, and every output is unchanged: verified
the cycle result equals the old formula over wide ranges and at the i32/i64
extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 =
fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/
`datetime_from_serial` only ever hand the converters in-range years, so their
serials are byte-for-byte the pre-chrono ones (pinned by a new test).
New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both
sides of the epoch, round-tripping, with values from the old formula; the
existing leap/month-length-beyond-range test still passes unchanged. Removed the
AGENTS.md "narrow fallback" exception.
Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check;
clippy --all-targets -D warnings.
* time: preserve out-of-range years in the log timestamp instead of the epoch
`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).
apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".
* Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
* Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
- Shell: [[ ]] for conditional tests, positional parameters copied into
named locals, explicit return at the end of the env.sh helpers.
- PowerShell: the desktop-shortcut smoke test writes its progress with
Write-Output instead of Write-Host.
- Web: the viewport no longer sets user-scalable=no, so people can zoom
the page.
- range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit).
- Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With row 1 frozen, Page Up could land on A1, which is always on screen, while the scrolled rows
stayed a page down (row 10, Page Down, Page Up, Page Up ends on row 1 with rows 2-9 hidden). The
same held for Ctrl+Up into frozen rows and Ctrl+Left / Alt+Page Up into frozen columns.
Arrow and paging keys now record the cell they moved from (`GridState::reveal_from`). When such a
move goes from the scrolled rows (columns) into the frozen ones, the scrolled area goes back to
its top (left). Other reveals (clicks, Find, Go To, Name Box, Undo, the control channel, formula
pointing) leave it unset and keep the view where the user scrolled it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A left-drag of the fill handle filled at once and offered no choice. A button now appears below the filled range; its menu undoes that fill and reruns edit.autoFill in the chosen mode. It goes away when the workbook changes or the selection moves.
A Ctrl+click went to the add-to-selection branch, so the link under the pointer was never followed. It now goes through follow_link like a plain click (asks before opening web addresses, jumps to references).
stapler staple/validate fetch the notarization ticket from CloudKit, which
sometimes fails with "The network connection was lost" (Error 68). That
failed this release's macOS job twice in a row. Both steps are idempotent,
so retry up to 5 times with backoff, like hdiutil_retry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Home > Format as Table on a cell or range inside an existing table
failed with "A table can't overlap another table." Excel applies the
picked style to that table instead. The command now returns that table
and its range, as it does for a new table. A range crossing a table's
edge is still rejected.
Without a range, the active area of the selection decides, as it does
for Insert > Table. The native Format menu's Format as Table now runs
home.formatAsTable instead of insert.table.
A cell that another cell's formula calculates on demand got its own
evaluator, which started a new 896 KiB stack budget wherever it was
called. Two cells that each count down from 131 with a LAMBDA, one read
at the deepest call of the other, overflowed a 1 MiB thread.
The budget of a formula calculated on demand is now counted from where
the outermost formula started. When it runs out there, the formula is
calculated again from the top, as a formula needed at the nesting limit
is, so its value does not depend on the order cells are calculated in.
Found while testing crates/formula/src/printer.rs:134
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- The workbook's active sheet is an atomic set in place (Workbook::active_sheet /
set_active_sheet), so a window swap or sheet switch never clones the
Arc<Workbook> or marks it dirty. Selections already live in DocState and the
window views. Save records the sheet of the most recently active window.
- A dialog taken out of its window while it runs goes back to that window when
a command run from it switched windows (dialogs::show, ui.dialog.confirm).
- view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?}
returns the visible windows, active first; the desktop moves and sizes the
native windows over the main window's monitor. New Window and Arrange All
buttons on the View ribbon; Arrange Windows dialog.
- Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP.
- automate.trustScripts: only a missing `trusted` defaults to true; a
non-boolean value is an error and leaves the workbook untrusted.
- The refusal message points at Trust Scripts in View > Macros (Alt+F8)
and at automate.trustScripts.
- Catalog row for automate.trustScripts; parity checklist regenerated.
- Script steps run inside an io::ScriptSandbox (thread-local) so
read_file/write_file refuse whatever the parameter is called, and a
step that raises OpenUrl is dropped and stops the script. The id/param
deny list stays as the up-front refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
after_engine, show_open_warnings and alert bring the per-window state in line
with the engine's active window first, so a notice, message or toast from
opening a workbook (also by a script on the session) lands in the opened
workbook's window instead of being parked with the previous one.
Each engine window is drawn in its own native window through craft-window's
immediate-viewport driver (desktop only; the web app keeps one window). The
root viewport shows one window and adopts another when its window closes.
- WindowUi: the cell draft, text-box draft, dialog, Backstage, message,
toast, notice, grid state, name box, key tips, selected chart, Tab run and
edit mode are per window. SheetApp's fields hold the active window's; the
others are parked and swapped in when a window is drawn or activated, and
after any command that switched windows.
- Session::show_window makes a window current for drawing without counting
as an activation.
- The ribbon in each window acts on it; menu bar commands and scripts go to
the window focused last. Leaving a window enters its draft as Enter would.
- A window's close button runs window.close: one of several windows on a
workbook closes without asking, a workbook's last asks when unsaved.
SheetApp::request_quit (macOS Quit item, Dock quit) asks about each unsaved
workbook in its window; Cancel stops. Save Changes answers moved to
SheetApp::answer_save_changes.
- The last window closed quits on Windows and Linux; macOS hides the root
and keeps running until New or Open.
- Fonts are bound for every open workbook, since each may be showing.
craft-window is pinned to the aiongg/craft-libs window-list branch until it
merges into storytold/craft-libs.
Session keeps a window list (stable ids, per-window selection, active sheet
and per-sheet selections swapped into the workbook without dirtying it)
in place of the index-based hidden list and the copying New Window.
Titles read Name:1 / Name:2 for a workbook with several windows.
File > Close closes one window of several; window.close, window.list and
window ids for window.activate, view.switchWindows, view.hideWindow and
view.unhideWindow. Control channel and MCP tools take window/doc ids;
scroll positions and cell drafts belong to their window.
With headings hidden too, a level button sat across the empty column band
at the grid's top edge, so a click there still ran it. Outline hit testing
now stops when both bands are empty. The parity doc no longer lists Ctrl+8
as missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The offscreen snapshot never started the background font scan the app runs
from its first frame, so a workbook's installed fonts (Helvetica Neue, say)
were drawn in the Calibri substitute. Catalog them up front and run a few more
frames so they bind, as in the app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sheet can hide its outline's level buttons and brackets while keeping
the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't
modelled, so such sheets showed a wide outline band Excel doesn't.
- model: Sheet::show_outline_symbols (default on, also for older JSON).
- xlsx: read and written on the sheet view.
- engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable.
- ui: no outline bands while it's off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every string in `values` went through typed input, which reads it with en-US
rules, and nothing said so:
"05.04.2026" (dd.mm.yyyy) → the date 4 May 2026
"02/11/2026" (dd/mm/yyyy) → the date 11 February 2026
"2.500" (thousands) → the number 2.5
"4.25 %" (text) → 0.0425, formatted 0.00%
A workbook built from Turkish-format exports therefore held different values
than the script put in it, and the only way to notice was to read the saved
file back cell by cell.
`parse: false` stores every string as the text it is. A formula is still a
formula — `=` means a formula in a cell, and the ticket asks for that — and
numbers, booleans and null are unaffected either way. A leading apostrophe is
part of the text rather than the marker that makes it text, since nothing is
being marked.
The default is unchanged: `parse` is absent in every existing call, and those
calls behave exactly as before. A `parse` that is neither true nor false is a
usage error rather than a quiet default.
The test pins both sides — the swapped date, the decimal point and the
percentage under today's default; each of the six strings stored verbatim with
`parse: false`, with `=1+1` still calculating 2; numbers and booleans either
way; and the refused `parse` values. Without the change the first string comes
back as a date.
A number too wide for its column is drawn as ####. With a repeat fill
(`*` in the format, as in the Accounting format) the fill step then
rebuilt the text from the formatted value, so it overflowed and, being
right-aligned, lost its leading digits: 2,509.00 CHF in a narrow column
was drawn as "509.00 CHF". The fill now leaves #### alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A script line whose parameters sat under a key the runner does not read ran the
command with no parameters at all, and the run still exited 0:
{"command":"home.insertSheet","args":{"name":"Report"}}
{"command":"home.insertSheet","name":"Report2"}
made Sheet2 and Sheet3 instead of Report and Report2, and said nothing. A typo
in a script produced a valid, wrong workbook.
A line is read for `command` (or `id`) and `params`; any other key is a usage
error now, naming the keys and the line, and the run stops before the workbook
is written:
gridcraft-cli: s.jsonl:2: unknown key `args`: a line is {"command": "id", "params": {…}}
The usage text says what a line looks like, which is the other half of the
report — the format was not written down anywhere, so the reporter found it by
trying `args`, a flat object and `id` in turn.
Tested at both levels: `parse_command` for the keys, their sorted list when
there are several, and the forms that are read (`id`, `params`, `id={json}`,
comments); and a run of a four-line script that stops at line 2 without writing
the file, then the same script corrected, whose sheets come out named. Without
the check the mistaken line parses again.
An unknown key *inside* `params` is still ignored — the rest of #2256 — and
needs the command's parameter list, which the engine does not expose yet. Noted
on the issue.
Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
From the local CodeRabbit review: the result was bounded per match, but
one match's expansion was built whole first, so a replacement repeating
$0 10,000 times over a 32,767-character match allocated about 330 MB
before the check. The replacement is now split into pieces as regex's
Captures::expand reads it (template_expands_like_regex checks they agree)
and appended piece by piece within the bound: 327 MB peak became 7 MB
(regexreplace_bounds_its_result).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
- Text coercion: digits too many for a finite number are not a number,
and a fraction must come out finite, so "1 <400 digits>/<400 digits>"
(inf/inf) no longer coerces to NaN (test overlong_digits_stay_text).
- VLOOKUP/HLOOKUP and the D-functions' field index refuse NaN, which
passed their "< 1" checks and became index 0, then underflowed
(test nan_index_is_a_value_error; hostile_inputs_never_panic now also
passes NaN and infinity to every function).
- REGEXREPLACE replaces match by match and gives up once the result can
only be too long, instead of building every replacement first: the
32,767 x 32,767 worst case went from 1.05 GB peak to 7 MB
(test regexreplace_bounds_its_result).
#2165 (MAP's size check) is already fixed on main by the broadcast cap
(836b2698, #2182), whose test covers MAP.
Also adds my contributors/people.toml entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
Stopping a recording saved the macro with a nested macro.save while a debugged run's undo group could be open on the same workbook. Stopping the run without keeping its changes then deleted the macro that macro.record had reported as saved. Stopping now fails with "Finish or stop the macro first" under macro.record before anything changes, and the recording goes on, as when a save fails for another reason, so stopping again after the run ends saves it.
A recording keeps up to 100,000 commands, and inlined macro and script runs can add more, but a macro holds at most 10,000 steps. Stopping truncated the converted steps to that limit without saying so: dropped counted only the recorder's own overflow, so a 10,005-step recording saved a 10,000-step macro and reported nothing missing. The steps cut at the macro limit are now added to dropped.
Stopping a recording with a name that a macro already has (in any case) saved over that macro's
steps, inputs and description, with only Undo to get them back. A taken name now gets a suffix
("Tidy (2)"), and the save passes replace: false as a backstop.
The catalog went from 274 / 290 to 275 / 291 with View › Macros › Record Macro, and
parity-checklist.md says so, but the ROADMAP.md headline, ui-parity.md and target-app-parity.md
still said 274 / 290 (and 290 catalog ids). They now match, with revision rows.
Stopping Record Actions saves a script into the workbook, which the debugger lock refuses while a
macro there is paused. The refusal came after Record Actions had taken its recording, so the
recording was lost. Stopping is now refused before anything changes, and stopping again after
the run saves the script.
The intro said a macro run rolls everything back on an error, but a step that exports or prints to a path writes the file before a later step can fail, and the Running section says that file stays. The intro now limits the guarantee to workbook changes.
`col("Amount")` read the header through the cell's display text, which is the formula while
Show Formulas is on, so a header such as ="Amo"&"unt" matched nothing. The header is now the
cell's value as formatted, whatever the sheet shows.
A visible workbook-scope name such as `_gc_macro_0_0` matched the storage key and was read as a
macro and removed when the workbook opened. Macro storage is written hidden, so only hidden names
are read back; a person's own name of that shape stays a name.
Stopping a recording converted and saved it against the active workbook. For example, starting a
recording in Book1 and switching to Book2 before stopping saved the macro into Book2, inferred the
input's header and column types from Book2's cells and looked up inlined macros and scripts
there. The recording is now converted and saved against the workbook where it started, and the
active workbook stays active. If that workbook was closed, stopping returns an error and ends the
recording.
Session::execute checked the debugger lock only for top-level undoable commands. A command that is not undoable could run edits as nested commands and skip it: stopping Record Actions while a macro in the same workbook was being debugged saved the script inside the run's undo group, and stopping the run without keeping its changes deleted the script again. The lock now applies at every depth, and only the debugged run's own steps go ahead.
The policy summary said file commands never run in a macro, but File >
Properties runs, and exports and printing run: without a path they only
produce bytes, and with one they need the permission and approval. The
summary now names what is denied and what runs, and the paragraph is
wrapped to the file's line length.
`Name:` read a name only up to the first period, so `Name:Sales.Q1` looked up `Sales` and
treated `Q1` as a method, and a workbook name called Sales.Q1 could not be used in a macro,
alone or as `Name:Sales.Q1.cell(1,1)`.
The name now runs through periods, and the longest defined name or table that exists and ends
at a period (or the end) is used; what follows is read as methods. So `Name:Data.row` is a
name called Data.row, and `Name:Sales.offset(1,0)` still moves Sales. When no name matches,
the error names the text before the first method, and a bad method after a dotted name is
still refused. Tests cover direct resolution, reference parameters, `$ref` and `{{...}}`,
with and without a chained method.
Opening a workbook gave each clashing macro name a suffix by trying " (2)", " (3)"... in turn,
and each try scanned every macro accepted so far. A file with 1,000 macros of the same
255-character name made about 300 million name comparisons.
Load now keeps a set of the names in use (ASCII case-insensitive, as find compares them) and,
for each shortened name and suffix width, the next suffix to try, so each suffix is looked up
about once. The result is unchanged: the smallest free suffix, within the name length limit,
and the renamed macro still passes the final checks. unique_name keeps its signature. A test
opens, saves and reopens 1,000 same-name macros in JSON and xlsx and keeps them all, and
another counts lookups instead of timing them.
An Automate script run while recording was replaced by all of its steps except automate.* ones,
without the policy check that top-level commands get. For example, a script that runs
window.activate or macro.list produced a recorded macro that the runner refused on playback. The
steps of a recorded script run, and of an inlined macro run, now go through the same policy as
top-level commands; refused ones are left out and listed as skipped.
Approvals were stored as (document, FNV-1a hash of the macro's JSON).
FNV-1a is not collision resistant, so a crafted workbook could hold a
harmless macro and a different one with the same hash, and approving
the first would let the second run without asking.
The key is now the macro's canonical JSON text itself (object keys
sorted), so two different macros never share an approval.
macros::hash is replaced by macros::fingerprint.
col("...") always matched against the first row of the range, whatever
its header flag. On an input declared without a header row,
Input.col("Amount") silently picked the first column whose first data
cell read "Amount". After .data, the natural Input.data.col("Amount")
failed, because the first row was then a data row. The header row is
now carried through data, and col("...") is refused with a message when
there is none (an input without a header, a Name: base, or after any
other method).
Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde
dropped the fields this build doesn't know, and the next save wrote the reduced macro back
with `v: 2`, so a newer build would read it as a complete version 2 macro.
A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks
the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is
left out with the usual single warning (which now names a newer format), and the rest of the
workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as
controls.
A macro run while recording was replaced by every command in it, including both branches of an
If and the body of a Repeat, whenever it had no inputs. For example, recording a run of a macro
with an If played back both the then and the else commands without a condition, and commands
using Row, Column or Output failed on playback. The run is now inlined only when the macro has no
inputs, no output and only plain commands (its disabled steps are left out); otherwise it is left
out and listed as skipped.
When a debugged step panicked, the nested execute rolled the run back and dropped the debugger
before advance could reattach the machine, so the caller got "No macro is being debugged"
instead of the failed step. For example, stepping into a step whose command panics lost the
step path and the panic message. advance now sees that the run was already rolled back and
returns the usual failed result with the step error.
The policy denies window.* commands, but the View tab's window commands
are view.*: a macro could run view.newWindow, which adds a window that
the run's rollback doesn't remove, or view.hideWindow and
view.unhideWindow, which change the session's hidden windows.
The policy now denies those three. Arrange All changes nothing in the
engine and Switch Windows only lists windows, so both stay allowed.
is_token looked only at the leading identifier, so on a workbook with a
sheet called Output (or Row, Column, or the name of a declared input),
"Output!A1:B2" was read as the Output token, and the step failed with
"the macro has no output anchor" or "unexpected '!A1:B2'". This hit
reference keys, $ref and {{...}} alike, including references the
recorder writes for other sheets. A base followed by '!' is now a sheet
name, and the text resolves as a plain reference.
The recorder made every unqualified reference relative to the example input, whatever sheet the
step ran on. For example, starting a recording on Sheet1 with B2:C5 selected, activating Sheet2
and setting C4 recorded Input.cell(3,2), so playback wrote Sheet1 instead; a formula entered on
Sheet2 was changed to refer to Sheet1's cells. Each step now keeps the sheet it ran on, and only
references on the input's sheet become tokens.
macro.debug runs a macro one step at a time. start checks the macro as
macro.run does (policy, approval, inputs, footprint and output rule)
and pauses before the first step; step runs one step; continue runs to
the next breakpoint; runTo runs to a chosen step path, stopping at
breakpoints on the way; stop ends the run and keeps its changes or
rolls them back; state reports where it is. Breakpoints are the steps'
own flags plus a list of paths given with any action (breakpointsOnly
makes the list replace the flags), and they stop in every pass of a
Repeat.
The run lives on the session between calls, inside an undo group that
spans them (group_begin and group_end), so a debugged run is one undo
step "Run Macro: <name>", as a normal run is. A failing step ends the
run and rolls it back; so does a Stop step that asks for a rollback.
While a macro is being debugged, edits of the workbooks the run touched
(its own, and any other a step changed) are refused at the top level
with "Finish or stop the macro first"; other workbooks stay editable,
and selecting and queries still work. Every answer gives the next
step's path, kind, command, parameters as they would run now (tokens
resolved), the Repeat pass, the parameters of the step that just ran,
the steps executed and the cells changed so far. Each action gets its
own time limit.
If a command panics while a step runs, the session's panic guard rolls
the debugged run back as well, so its undo group never stays open.
footprint always ran its dry run with the default 60 second limit, so
macro.run with timeLimit 5 could run for over two minutes (a dry run,
then another after a shift) and fail with "ran longer than 60 seconds".
footprint now takes the run's clock and limit. make_way starts the
run's clock before the dry runs, so the checks and the run share one
limit, and macro.check takes timeLimit too.
Workbook::macros was read straight into typed macros, so one entry this
build couldn't read (a step type from a later build, an unknown
onConflict value, an entry that isn't an object) failed the whole file
with "not a GridCraft JSON workbook". The field is now read as raw
entries into Workbook::macros_read, and macros::load reads each one
with parse_macro, as for xlsx: short-form steps are accepted, the depth
limit is checked before deserializing, and a bad entry is dropped with
the same warning. Only the first 1,000 entries are read, as in xlsx.
Stopping a recording took the recorded commands and where recording started before saving the
macro, so a failed save threw both away. For example, stopping with a name that holds a control
character, or in a workbook at its macro limit, ended the recording, and stopping again with a
good name found nothing to save. A failed save now puts the recording back, so stopping again
saves what was recorded.
The footprint dry run stops at an approved file step and at Solver, but
make_way took a partial footprint with no conflicts as proof that the
run was safe, also after a shift. A macro that exported a CSV and then
set B1 ran under onConflict fail or ask and overwrote B1 without asking.
When the footprint is partial and the rule is not overwrite, the run's
machine now checks every step as it runs: a step that writes over
cells, sheets or objects that held data when the run started stops the
run and rolls it back, with a confirmation under ask and an error
otherwise. make_way now returns the machine for the run along with
whether it shifted.
Step parameters can name where they act through tokens that are worked
out when the step runs, so a macro made on one block of data works on
another: a declared input (Input, or any other input's name), Output
(the output rule's anchor), Row or Column (the current item of the
Repeat step around the step) and Name:Total (a defined name or a
table's data). Methods narrow or move the range: col(n) or
col("Header"), row(n), cols(a..b), rows(2..), cell(r,c) relative to the
top-left cell, offset(r,c), resize(h,w), data (without the header row),
below(n) and right(n). Methods chain.
A token is written as {"$ref": "..."} anywhere, as the whole string of
a reference parameter (cell, range, target, keys ending in Cell or Range
and similar), or as {{...}} inside any string, which is how a formula
refers to its input. Other text parameters are never read as tokens, so
a cell can still hold the word Row and a chart can still be of type
column.
Every token becomes A1 text, sheet-qualified when it is on another
sheet, before the command runs, so no existing command changes. Plain A1
references in a step stay absolute. A reference that runs past the edge
of the sheet is reported apart from other mistakes, so the footprint
check can tell the two apart.
load listed every _gc_macro_ item, then read each one with a scan of
all the workbook's names, and removed each with another full scan.
Damaged items didn't count toward the macro limit, so a file with
20,000 damaged items took 20,000 scans of 20,000 names (seconds, and
far more for a larger file). The names are now taken out of the
workbook and grouped by item in one pass; only the first 1,000 items
are read, and the rest are counted in the warning without being read.
When a file held two macros with the same name, the second was renamed
"name (2)" without room for the suffix. Two macros with the same
255-character name both opened, but the second name grew to 259
characters, was saved that way, and the next open dropped that macro as
over the limits. unique_name now shortens the name to make room for the
suffix, and load checks the macro again after renaming it.
Commands: macro.list, macro.get, macro.save (a macro object or its
fields; replace defaults to true), macro.delete, macro.rename and
macro.record, beside macro.run, macro.check and macro.debug. Saving,
renaming and deleting are undoable. The catalog gains View › Macros ›
Record Macro (parity 275 / 291).
The recorder builds on the recording buffer of top-level commands, so
nested commands are never recorded twice and trimming the journal loses
nothing. When it stops, commands a macro may not run are left out and
listed; a macro run while recording is replaced by its steps when it
has no inputs, and an Automate script run by the script's steps;
sheet.activate and selection.set steps are added where the sheet or
selection changed between commands without one, and runs of them
collapse to the last. With relative references (the default) the
selection when recording started is the example input: references in
and near it become tokens (Input.col(2), Input.rows(2..),
Input.col(2).below, Input.row(4).right, Input.cell(r,c)), exact
references in formulas become {{...}}, and the input's width, header
and column types are inferred from the example. A recording replayed on
a longer block of data writes in the matching places.
A toggle such as Bold is recorded with the state it set ("on": true),
as Excel records Bold = True, so a replay sets it instead of flipping
it again.
Automate scripts are unchanged: automate.* keep their commands,
parameters, storage and results, and View › Macros still lists the
scripts and a preserved VBA project.
A path given as a token (`{"$ref": "Input"}` or `{{Input}}`) became a file name only when the
step ran, after the policy had passed it, so a macro without permissions or approval could write
a file, even from macro.check. Exports also wrote to blank paths the policy ignored. Any `path`
other than null now needs the permission and approval, and the runner checks the policy again on
the resolved parameters before the command runs.
Only parse_macro refused a name holding a line break or other control
character. Macros that came from the macros field of a JSON workbook
were checked with check_limits alone, so a file could bring in a macro
named "a\nb" that macro.save and macro.rename would refuse. The check
now lives in Macro::check_limits, so every path applies it, and such a
macro is dropped with the usual warning when the file is opened.
macro.run runs a macro as one undo step ("Run Macro: <name>"); an error
at any step rolls everything back and names the step and its path (3,
2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts:
- Policy (macro_policy). File, app, window, automate and macro commands
and Undo and Redo never run in a macro. Reading or writing files
(data.getData, data.fromTextCsv, insert.picture, Page Layout
Background, or an export with a path) needs the command in the
macro's permissions and the person's approval. A macro in a workbook
opened from a file asks before it runs. Approval holds for the
session until the macro's content changes; it is asked for with the
new EngineError::Approve and given with approve: true. A step that
would open a dialog or a web page fails instead.
- Inputs. Each declared input is bound (given, its default, or the
selection for the first one), whole columns shrink to the used range,
and the shape and column types are checked with messages that name
the cells: "Input column 2 (Amount) must hold numbers; C4 holds text".
- Footprint. Unless the macro may overwrite, it first runs in dry-run
mode on the real workbook inside an undo group that is rolled back,
with the session's clipboard, journal, windows and the document's
trust put back. CellStore::diff on every sheet, plus the cells
commands wrote (DocState::touched), sheets and objects, gives what it
would write outside its inputs; cells that only recalculated do not
count. Writing over data, deleting a sheet or deleting an object is a
conflict: ask (the new EngineError::Confirm, so programmatic runs fail
unless they pass onConflict or overwrite), fail, overwrite, or shift
the data down or right with Insert Cells and check again. A step that
would fail, for example past the edge of the sheet, fails the run
before anything changes. File steps and Solver stop the dry run early
and are reported.
- Steps run on a machine with an explicit frame stack: Repeat over the
rows or columns of a range with Row or Column bound, If on one cell
with Excel's comparisons, and Stop, which keeps the changes or rolls
them back. A run executes at most 100,000 steps within a time limit
(60 s by default, timeLimit to change; the web build has no clock, so
only the step limit applies there).
macro.check reports the bound inputs, the output anchor, the approval
needed, the footprint and the conflicts without changing anything;
nothing from an unapproved macro runs, not even as a dry run.
A macro is plain data in the model (model/src/macros.rs): a name, a
description, an optional shortcut, declared inputs (shape, header,
column types, non-empty, default reference), steps, an output rule
(anchor and what to do on a conflict) and the commands it may run that
read or write files. A step runs a command (id and params) or is one of
three bounded control steps: Repeat over the rows or columns of a range,
If on one cell's value, and Stop with a message. Every step carries
enabled, breakpoint and note flags. These are GridCraft macros, not VBA;
a VBA project in a file stays preserved and is never run.
Workbook::macros holds them. The JSON format keeps the field. In xlsx,
io::save_bytes writes each macro as one chunked hidden-name item,
_gc_macro_<n>, and io::open_bytes takes the items back out of the names,
so the names never linger in the open document.
Limits are checked whenever a macro is read from a file or a command:
at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep,
names and notes of bounded length, and at most 4,194,304 characters of
JSON, so that saving a workbook never fails because of a macro; nesting
is checked before the steps are deserialized. What is over or damaged
is dropped with one warning, and clashing names get a suffix.
Automate scripts keep their own storage (_sc_scripts) and are neither
read nor changed by this.
The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.
A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.
The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.
The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.
- File and window commands stop when the workbook refuses the cell or
text-box draft (validation Stop, bad formula, protected text box): the
draft stays open in its cell with the reason shown, to fix or Esc. The
per-frame backstop brings a refused draft's workbook back to the front.
- An F2 draft committed with its text unchanged writes nothing: no undo
step, and the workbook stays clean through Save, Print or New.
- macOS Window menu: the active workbook's check is put back every poll,
since choosing a check item flips it.
A cell being typed when File > New ran (macOS menu, control channel) stayed
open over the new workbook, and Enter wrote it there; the first workbook
stayed clean and nothing in the UI reached it.
- Every file.* and window command (window.activate, view.newWindow,
view.hideWindow, view.unhideWindow, view.switchWindows with a doc) first
commits the cell and text-box drafts as Enter would, or drops a draft its
workbook refuses, still reporting why. Finder, argv and dropped opens go
through file.open and get the same.
- The cell editor records its workbook's uid; the grid draws a draft only
over that workbook, and a commit always goes into it.
- view.switchWindows takes a `doc` uid to bring that workbook forward and
lists index, doc, title, dirty, active and hidden. Menu-style calls with
no doc open the View tab's new Switch Windows dropdown.
- macOS: the Window menu lists the open workbooks (unsaved bullet, active
check), and Quit closes the window so unsaved workbooks are asked about.
They run the process-wide installed-font scan, whose faces bind from the
next frame; in the same process as the header drag tests they changed text
widths under them, which failed those tests about 1 run in 4 in parallel.
Conflicts:
- crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 18 entries).
- crates/ui-egui/tests/file_menu.rs: both language rows kept.
- docs/localization-parity.md: one Last updated line naming all changes; both revision rows kept, newest first.
Conflicts:
- crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 17 entries).
- ROADMAP.md: one Last updated line naming both changes; both progress-log rows kept.
- docs/localization-parity.md: one Last updated line naming both changes; both revision rows kept.
Conflicts:
- crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 16 entries).
- docs/localization-parity.md: both revision rows kept.
crates/ui-egui/src/lib.rs: sheet re-activation first, then main's unchanged-empty-commit check and the commit chain.
crates/ui-egui/src/tabs.rs: union of test imports.
crates/ui-egui/src/editor.rs: kept both appended tests
crates/ui-egui/src/grid.rs: kept the Table heading filter-button check, then point_header(.., mods.shift, mods.command); kept format_painter_apply and the extend/add point_header
crates/ui-egui/src/grid.rs: keep main's touch-pan guard around interact, add 1095's update_cursor call after it.
docs/ui-parity.md: one Last updated line, change notes combined; both history rows kept.
Conflicts:
- crates/model/src/lib.rs, crates/xlsx/src/read.rs, crates/xlsx/src/write.rs: use lists unioned (ShowObjects and the phonetic types)
- docs/file-format-parity.md: main's header and rows kept (#1692 collapsed the three duplicate .xlsx rows; they stay), phonetic-runs text added to each; Last updated 2026-10-20
Conflicts:
- crates/ui-egui/src/i18n.rs: mod it and mod ja both kept; Language::Ja dispatches to ja::translate; the shape test (PR #831) iterates ja::CORE, which replaces JAPANESE.
- docs/localization-parity.md: both revision rows kept, newest first.
crates/ui-egui/src/i18n/de.rs: kept both sets of entries; dropped the duplicate Unprotect Sheet… line
crates/ui-egui/src/tabs.rs: kept active_sheet_protected() and the protectSheet dialog id, tr() on both button labels
ROADMAP.md: all progress-log rows kept.
docs/ui-parity.md: one Last updated line, change notes combined; both history rows kept; ribbon row wording tidied.
crates/ui-egui/src/dialogs.rs: window width match keeps both zoom and pasteSpecial arms
docs/ui-parity.md: status line combined, change-log rows both kept
crates/engine/src/cmd/insert.rs: kept both sides: PR #884's is_string_literal and cf_style_json, and PR #2022's check_cf_* before edit(); 884's kept string literals parse
crates/ui-egui/src/grid.rs: kept both GridState fields (context_menu_frame, fill_menu); previous_popups tuple has four elements inside the touch_pan branch; release match has Drag::Rows|Cols, then the two Drag::Fill arms
docs/ui-parity.md: kept HEAD header line, both change rows
crates/xlsx/src/styles.rs: family/charset block ported into the dxf.is_none() branch of font_xml (PR #1225 made it take Option<&DxfStated>)
docs/file-format-parity.md: header Change line keeps both sides' clauses
Conflicts:
- docs/file-format-parity.md: header and the three .xlsx rows keep main's text plus the show-objects entry; both log rows kept
- docs/ui-parity.md: header keeps main's text plus Ctrl+6; both log rows kept
Conflicts:
- docs/file-format-parity.md: kept main's three .xlsx rows (the PR's docs hunk replaced them with one) and added the theme-font wording to each; header keeps main's change list plus this PR's phrase (updated 2026-10-20)
Entering them through the parser applied percent entry to Percentage-formatted
cells (a stored 0.05 would not round-trip). Test: scenario_show_restores_a_percentage_cells_stored_number.
crates/ui-egui/src/grid.rs: kept object_menu (2084) and error_check_tag (2095), both fns and both calls.
crates/ui-egui/src/i18n/de.rs, pt.rs: union of the entries.
docs/ui-parity.md: one status line naming both changes, both history rows.
crates/ui-egui/src/control.rs: kept #1477's unconditional build_params call
crates/ui-egui/src/dialogs.rs: build_params pub(crate) once, with #1628's advancedFilter arm
docs/ui-parity.md: status line combined, change-log rows both kept
A sheet AutoFilter that starts right of the table's first column is not folded
(its colId offsets would hit the wrong column); the sheet autoFilter stays
suppressed for any table it intersects, as before. Test added.
crates/engine/src/tests.rs: kept both sides' new tests (journal tests from #1230, HTML export tests from the PR).
docs/file-format-parity.md: header Last updated 2026-10-21 with both change clauses; both revision-history rows, newest first.
The generator no longer probes insert.recommendedCharts (the engine command errors without a chart type; the UI opens the dialog) and emits an assertion that the recommendedCharts dialog is open. keytips_generated.rs updated to match.
crates/engine/src/lib.rs: kept both the journal caps (#1230) and the undo byte budget; separate constants and call sites.
crates/engine/src/cmd/edit.rs: undo pushes through push_history; the redo entry keeps the undone edit's selection (#1855 behaviour).
crates/engine/src/tests.rs: kept both tests (redo_selects_the_redone_cell and the two history-bound tests).
crates/ui-egui/src/ribbon.rs: ported the sel clamp, sized scroll area and position-based sel into combo_popup, keeping row_w and the filter-only separator
Conflicts:
- crates/engine/src/cmd/data.rs: kept both; the missing-formula1 guard comes right after the kind match, before the operator and list_source handling
home.deleteRows {rows:"2"} and home.deleteColumns {cols:"B"} failed with
"not a range"; delete_areas now parses with parse_lines. The PR's test
covers both.
Conflicts:
- contributors/people.toml: both new contributors kept
- crates/ui-egui/src/theme.rs: LINUX_CJK_DIRS has the Flatpak mounts plus the chromeos entry (22 entries)
- docs/localization-parity.md: both status lines, text and history rows kept
A stored cell with no value and no formula falls through to spill in
value_ref; the scan now asks value_ref for the row where the stored run
stops. Docs corrected; tests cover a style-only stored cell over a spill
value.
A workbook opened with GridCraft scripts (`_sc_scripts`) starts untrusted:
automate.runScript refuses them until the user trusts the workbook for the
session (Macros dialog > Trust Scripts, i.e. automate.trustScripts) or the
caller passes `trusted: true` for that one run. Workbooks without scripts,
and scripts written in the session, are unaffected.
Whatever the trust, a script whose steps reach outside the workbook
(file.*, link.*, or any `path` param: Get Data, From Text/CSV, Insert
Picture) is refused before any step runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clipboard HTML is read only as far as the pasted text's shape (clamped
to the sheet) and at most 1,000,000 cells; colspan/rowspan are clamped
to the sheet extent before expanding, and rowspan now keeps later rows
in their columns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zip2 (binary operators, TEXT), zip3 (array IF) and MAP now compute the
broadcast cell count with checked multiplication and return #NUM! when it
exceeds MAX_CELLS, instead of allocating (abort) or wrapping on wasm32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parenthesised negatives were parsed by recursing once per level, so runtime
text such as REPT("(",32767)&"1"&REPT(")",32767) overflowed the stack. Strip
the parentheses in a loop, toggling the sign per level; results are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every area, array and value of one SUBTOTAL/AGGREGATE call is charged against MAX_CELLS before any cell is read; past it the call returns #NUM!. A value stored in XFD1048576 no longer makes SUBTOTAL(9,A:XFD) read ~17 billion cells into a vector.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LINEST, LOGEST, TREND and GROWTH built k x k normal-equation matrices
(plus two copies in sym_inverse, O(k^3)) with k taken straight from the
known_xs shape, so one formula could allocate gigabytes and abort on open.
More than 255 predictors is now #VALUE!, and more than 1e9 rows x k^2
multiply-adds is #NUM!, both checked before any allocation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.
Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.
When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.
The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.
A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.
GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.
cmd/stash.rs stores an item `key` as hidden workbook-scope names
_gc_<key>_0, _gc_<key>_1, ..., each a formula of string literals of at
most 255 characters (UTF-16 units, as Excel counts them) joined with &,
at most 8,192 characters per name. Reading concatenates the chunks in
order and stops at a gap; it also accepts the older single _sc_<key>
name. Names are matched case-insensitively and only at workbook scope.
Keys are limited to letters, digits, _ and ., items to 4,194,304
characters, and junk around or inside the literals, duplicate or
out-of-order chunks, non-JSON and over-deep JSON all read safely.
keys(prefix) lists the stored items that start with a prefix.
Custom views, scripts, the watch list and scenarios keep their current
storage; nothing reads or writes the new names yet.
docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.
Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.
The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.
Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.
Automate › Record Actions keeps recording through the journal as before.
Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.
While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.
Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
The pane ran `pivot.fields` on every frame while the active cell was in a
PivotTable report. That command reads the whole source list and collects
distinct labels for every field, so a 250,000 x 20 source made each frame
cost ~2.4 s and the window looked frozen (#2113).
Keep the answer in GridState, keyed by (doc uid, revision, pivot), the way
Page Break Preview keeps its pages. Any undoable edit, undo or redo bumps
the revision and drops it.
Known limit: Calculate Now / Calculate Sheet don't bump the revision, so
volatile formulas in the source can leave the tooltip samples stale until
the next edit; the field list itself only changes through undoable edits.
VERIFIED
- New test pivot_fields_pane_reuses_its_field_list_until_the_workbook_changes
fails without the change and passes with it.
- Release build, 250,000 x 20 synthetic source, pane open (ui.inspect):
main ~2,400 ms per frame throughout; this branch 2,528 ms for the first
frame, then 0.4-0.9 ms.
- `cargo xtask ci` passes all 8 steps on macOS with the separate clippy
fix from #2141 applied locally (main fails clippy on macOS without it).
NOT IN THIS CHANGE
- The report is still empty after opening a file saved with
refreshOnLoad="1" (second part of #2113).
- `pivot.fields` itself is unchanged; the first frame still pays one scan.
長形4号 is a 90×205 mm envelope (90×225 mm is 長形40号, a different one), so
the paper table gave it a height 20 mm too tall — and XLSX paper id 74, the PDF
MediaBox and the print preview all followed it. Use 205 mm (581.10 pt) and pin
the corrected size with a dedicated assertion; the ROADMAP progress row is
corrected too. 長形40号 is not added: it is a different envelope.
The Page Layout ▸ Size menu stopped at `B5 (ISO)` and never offered
`B6 (ISO)`, which the engine and the command docs support; add it.
Japanese Excel's B4/B5/B6 are the JIS sizes (257×364, 182×257 and 128×182 mm),
but Page Setup's `b4`/`b5` were the ISO ones (250×353, 176×250): a Japanese B5
worksheet printed 8 mm narrow. `known_paper` now reads `B4`/`B5`/`B6` as JIS and
keeps the ISO sizes reachable as `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. This also
fixes the XLSX round-trip, whose `paperSize` ids 12/13 are the JIS sizes.
Adds the Japanese papers Page Setup lacked: Japanese Postcard (はがき,
100×148 mm), Japanese Double Postcard (往復はがき, 148×200 mm), Japanese
Envelope #3/#4 (長形3号/4号, 120×235 and 90×225 mm), Japanese Envelope Kaku
#2/#3 (角形2号/3号, 240×332 and 216×277 mm), A6, and aliases such as
`postcard`, `chou #3`, `kaku #2`; the xlsx paper table gains ids 42 (ISO B4) and
43/69–74/88 so these names survive save and reopen (the ISO B5/B6 names have no
ECMA-376 paper id and stay engine-side).
The pageSetup/pageLayout.size parameter docs list the names, the Page Layout
Size menu lists them, and tests pin the point sizes, the PDF MediaBox for A4
(unchanged), B5, postcard, double postcard and kaku #2, landscape and
fit-to-page, the unknown-name Letter fallback, and the XLSX round-trip.
Docs: docs/target-app-parity.md print row + revision history, and ROADMAP.md
progress log, dated 2026-10-21.
The Japanese catalog's ribbon labels the review flagged are already in the head
("Save (⌘S)" is 上書き保存 (⌘S), "Insert\nSlicer" is スライサーの\n挿入, and the
catalog test asserts the new value), so only the docs needed work: docs/gaps.md
had no revision row for the §11 Japanese-catalog update. Add one dated
2026-10-20, and set the "Last updated" header of ROADMAP.md, docs/gaps.md and
docs/localization-parity.md to the same date as their new rows. Every existing
revision row keeps its original date.
si_body returned as soon as no valid run was written, so a shared string with a
<phoneticPr> and no <rPh> — a shape §18.4.8 allows — lost its properties on
save even though the reader keeps them. The runs and the properties are now
written independently: valid runs as before, then the valid <phoneticPr> when
the workbook has one; with neither, the <si> is exactly the plain t_el output.
property_only_shared_string_survives_open_and_save covers read, save and
reopen, and the si_body damaged-data test now expects the property-only output.
The write-time checks the reader also runs (sb/eb clamped into the UTF-16
length, empty readings skipped, type/alignment filtered through the shared
PHONETIC_KINDS/PHONETIC_ALIGNMENTS) were already in the head; the tests above
pin them, so no further change there.
Docs: the .xlsx row and the revision history now state that the shared string
table holds each text once, so duplicate <si> items with the same text but
different readings keep only the first reading (reported as a warning on open),
and that a <phoneticPr> without runs is kept. Both parity docs' "Last updated"
headers match their 2026-10-20 revision rows; the earlier rows keep their dates.
## CI Validation
`cargo xtask ci` completed successfully.
<details>
<summary>Build Warning</summary>
```text
warning: variants `At` and `Unknown` are never constructed
--> crates\ui-egui\src\backstage.rs:416:5
```
This warning appears to be pre-existing and is unrelated to the changes introduced in this PR. As it falls outside the scope of the current work, no changes were made to address it.
</details>
The build completed successfully, including the `wasm32-unknown-unknown` target checks.
```text
wasm32-unknown-unknown check (gridcraft-calc, gridcraft-chart, gridcraft-core, gridcraft-engine,
gridcraft-formula, gridcraft-functions, gridcraft-model, gridcraft-numfmt,
gridcraft-pdf, gridcraft-web, gridcraft-xlsx): ok
```
<details>
<summary>CI Parity</summary>
```text
CI summary:
ok fmt
ok clippy
ok test
ok assets
ok layers
ok wasm
```
</details>
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
tr reads | as a context separator, so translating the Open Hyperlink note
cut a URL with a pipe down to the part after it. Notes carry user data
(URLs, file and sheet names); one with a pipe is now drawn verbatim.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
Adds Romanian as an interface language: crates/ui-egui/src/i18n/ro.rs with 970 labels covering
every key of the other catalogs, registered in Language (switcher, app.language.set, ro-* system
locales). Context keys translate without their prefix; the File › Options kittest covers ro.
English formulas and formats; no proofing.
The catalog is an AI-assisted draft written from the meaning of the English labels; a native
speaker has not yet reviewed it line by line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
Page Setup, Insert and the other form dialogs drew their field labels in
English in every interface language, though their titles and OK/Cancel
were translated and most catalogs already carry the labels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
The reader took only <x:Visible/> from a note's VML shape and vml_xml
wrote every note with a fixed anchor (two columns right of the cell,
four rows tall) and a 108 x 59.25 pt style, so a plain open and save
moved and resized every note box Excel had stored.
Comment gets an optional NoteBox: the <x:Anchor> corners relative to the
note's cell (so the box follows the note when rows or columns move) and
the shape's width and height. The writer puts it back, clamped to the
sheet; notes made here, and boxes equal to the default, keep the
default box.
Korean Excel saves a cell's font under its Korean name (맑은 고딕 for
Malgun Gothic), but the font scan kept one family name per face, the
English one, so those cells were drawn in the default face. Each face now
keeps its typographic and legacy family names in every language, and a
workbook font is bound under the name the workbook uses, which is the
name cell_family looks up. A name two families share goes to the family
whose English name it is, else to the first face by path, so the scan's
directory order no longer decides it. Names bound under two spellings
share one copy of the font data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Numbers stored as text are skipped by SUM and friends, and GridCraft gave
no hint. Like Excel's background error checking, a constant whose text
would be a number if typed now gets a green triangle in its top-left
corner; with such a cell active, a warning button beside it opens a menu
naming the problem with Convert to Number, which converts every flagged
cell in the selection as one undo step.
- core: `parse::number_stored_as_text` (signs, thousands separators,
currency, percent, exponent, one parenthesised negative). Dates, times
and fractions kept as text are not flagged, as in Excel.
- engine: `cmd::formulas::number_stored_as_text(cell)` (constants only,
not formula results) and `formulas.convertToNumber {range?}`, which
keeps each cell's format.
- ui: the triangle in the grid and the warning button with its menu;
pt-BR and German strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ok_cancel` put the buttons in a right-to-left layout straight in the
window. That layout takes all the height it is offered, so every form
dialog (Greater Than…, New Name, Remove Duplicates, …) grew to the
screen's height with OK and Cancel floating halfway down. The buttons
now sit in a horizontal row as tall as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dialog was a generic form: one column of fifteen radio buttons and
three check boxes, taller than the window, with English labels (the form
didn't run them through `tr`). It is now laid out as Excel's: Paste and
Operation each in two columns inside a frame, Skip blanks and Transpose
side by side under them, and Paste Link as a button that pastes
references to the copied cells at once. The letter shortcuts keep
working: the options still come from the dialog's fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A data validation alert is the general message box, drawn over the cell
editor, which stays open so the entry can be fixed. The editor read Enter and
Esc first: the first Esc cancelled the edit behind the alert and only the
second closed it, and Enter tried the entry again so the alert came back. The
editor now skips its own key handling while a message is open over it, so Esc
and Enter close the alert (like its OK button). The message box also had the
keyboard focus and didn't give it back, so typing went nowhere until the cell
was clicked; closing it now asks the open editor for focus again.
Tests: ui-egui tests/validation_alert.rs
esc_closes_a_validation_alert_and_leaves_the_entry and
enter_closes_a_validation_alert_and_leaves_the_entry (both fail on main, and
without the focus change).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vertical scrollbar's track starts below the column headings (and any
frozen rows), and nothing painted the bar's column above it, so dark mode
showed a white square at the top-right corner of the grid. The whole
column now takes the track colour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid's right-click handler never hit-tested objects, so right-clicking
a chart opened the menu of the cell underneath it. And since any button's
drag start grabbed an object, a right press that moved a little started
dragging the chart and no menu opened at all.
Right-clicking a chart, picture, shape or slicer now selects it and opens
its own menu (Delete; for charts also Select Data… and Format Chart
Area…). Only the primary button moves objects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Save as Web Page wrote only the cell table, so pictures and charts on a sheet
were dropped. A sheet that has either now keeps its own geometry and draws them:
- The table gets the sheet's column widths and row heights (`table-layout:fixed`,
a `<colgroup>`, a height per row); hidden columns and rows take no room and
merged-cell spans count only visible lines.
- A picture is an `<img>` with a `data:` URI, a chart is inline SVG built from
the chart crate's drawing primitives (titles, axes, legends and data labels
included), each absolutely positioned at its anchor cell plus offset.
- Sheets without pictures or charts write byte-identical HTML.
Adds contributors/people.toml entry for bilalmughal.
Parity doc: state the Dutch coverage precisely (the shared key set the
German, French and Czech catalogs cover, not the larger Portuguese one).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds a Dutch catalog (`i18n/nl.rs`, 518 labels) covering every key the
other catalogs translate: ribbon, context menus, File view, dialog
titles and the Options page. `nl-NL` and `nl-BE` locale tags resolve
to Dutch. Updates localization parity and the roadmap.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
auto_row_height grew a row for every line break in a cell's text whether
or not the cell wrapped. Excel counts breaks only when Wrap Text is on:
with it off a multi-line value displays on one line and AutoFit gives a
one-line row. A file whose unwrapped cells hold pasted multi-line text
(the shape generating libraries write) opened with tall rows and nothing
visibly tall in them.
The fast path no longer treats a line break as a reason to measure, and
the measuring path lays text out over lines only for a wrapping cell.
Typing a break still turns wrap on (the Alt+Enter rule in cell entry) and
grows the row, as before.
Test: an unwrapped multi-line cell from a file keeps the default height
on open; wrap on grows the row; wrap off returns it; a typed break wraps
and grows. The open-time fit tests from #833 are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`=1+` reported "unexpected `)`", although no `)` was typed: entry closes
missing parentheses by appending up to eight `)`, and when every retry
failed it reported the last retry's error. It now reports the error of
the formula as typed ("unexpected end of formula").
`gridcraft_formula::parse_closing_parens` holds the convenience and takes
the parse function, so cell entry and the UI's Spanish formula path share
it instead of each having its own loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The theme watcher (DesktopTheme) reads the desktop's light/dark choice
from the XDG Desktop Portal over a blocking D-Bus call with no timeout.
zbus waits forever by default, so a portal that accepts the call and
never answers stalled the watcher's thread on its first read, and "Use
system setting" stopped following the desktop for the rest of the
session, even after the portal recovered.
The session connection is now built with a 2 s method timeout (REPLY,
shorter than the 4 s POLL). A timed-out call returns InputOutput, which
read() already handles by dropping the connection, so the next poll
asks again on a fresh one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115HyvML9WnuFRdmtMcLP5t
The Japanese catalog still lived inline in i18n.rs with its ribbon and widget
labels, so with the interface set to 日本語 the dialog titles and labels, message
boxes, Backstage/File-view items, template names and native menus showed in
English. This moves it to i18n/ja.rs and adds the 545 keys the Brazilian
Portuguese catalog translates beyond it: 943 keys, the coverage pt-BR has
(~43% of ~2,200 strings), in Excel for Mac 日本語版 wording.
CORE keeps the shared reference set every catalog covers, verbatim and in the
same order (the cross-catalog coverage test still points at it); DIALOGS holds
the new strings, and the lookup checks it first. One test parses pt.rs's source
so the two catalogs cannot drift apart again, one checks that every key is
unique across the two lists and every value is translated, and one spot-checks
the high-value strings.
Docs: the Japanese row and the "Where we are" bullet in
docs/localization-parity.md (943, ~43%), a revision row, the ROADMAP Languages
paragraph and progress log, and gaps.md §11.
xl/sharedStrings.xml can carry phonetic runs (<rPh sb="0" eb="2"><t>ふりがな</t></rPh>)
and a <phoneticPr> beside a string item. GridCraft read only the text and wrote every
string back as <si><t>…</t></si>, so a plain open + save deleted a Japanese workbook's
furigana.
Both are now read — offsets validated against the base text, runs with bad or missing
offsets, sb >= eb or no reading dropped with a warning, and never used to index — and
kept on the workbook as Workbook::phonetics, one entry per string text. The writer
emits them next to the <si> of the same text. The saved table holds each text once, so
two identical texts cannot keep two different readings: the reader keeps the first and
warns. Strings without phonetic data are written byte for byte as before.
Behaviour reference: ISO/IEC 29500-1 §18.4.6 rPh and §18.4.7 phoneticPr (Microsoft
Learn PhoneticRun/PhoneticProperties). Tests: crates/xlsx/src/tests/phonetic_tests.rs
(round trip, plain string, hostile offsets, shared text, unused data, comments and
inline strings). Docs: file-format-parity.md, localization-parity.md.
A theme's <a:fontScheme> carries latin, ea (East Asian) and cs (complex
script) typefaces for each of the major and minor fonts. The model held
only the latin ones and the writer emitted <a:ea typeface=""/> and
<a:cs typeface=""/> unconditionally, so a Japanese workbook's theme fonts
(e.g. 游ゴシック, MS Pゴシック) were silently deleted on open + save.
Theme gains major_font_ea, minor_font_ea, major_font_cs and
minor_font_cs (#[serde(default)], so older GridCraft JSON still loads;
empty means the file did not set one). The XLSX reader and writer
round-trip them escaped, and a theme without them writes exactly the
empty attributes it wrote before.
Rendering and font resolution are unchanged: the UI does not consume ea
yet, and wiring it in is a follow-up.
The French catalog once shipped with over a hundred translations sitting
on their neighbours' keys ("Close" read "Société"); main has since fixed
them. A catalog shifted that way still covers every key, so the existing
coverage tests don't catch it.
A new test checks every language's translations keep the shape of their
English: the same {placeholders}, and no line break in a one-line label
(the two big ribbon buttons that wrap are exempt). Plus spot checks of
French labels that were once misplaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The left end of a flat operator chain is not nesting, so (((...1)))+1 is
accepted whenever (((...1))) is, in Spanish entry and display.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
along: =1+1+... with 4,096 operands (8,192 characters, exactly Excel's
limit, which Excel accepts and calculates) overflowed the stack when
entered, because the derived Clone of Expr recursed once per operand
down the chain's left spine. Saving hit the same in the XLSX writer.
- Expr: Clone, PartialEq, walk and map follow the left spine in a loop
and recurse only into right operands and real nesting.
- XLSX: _xlpm. prefixing and the implicit-intersection scan follow the
spine too. The scan's depth limit of 64 wrote any chain of more than
about 64 operands as a dynamic-array formula; such sums are plain
formulas again (ranges in the chain still get array evaluation).
- Evaluate Formula borrows sub-expressions instead of cloning each
operator's subtree (quadratic memory on a long chain).
- Spanish formula entry and display no longer refuse chains of more
than 256 operands; the left spine does not count as nesting.
Tests run on a 1 MiB stack (the Windows main thread) with Excel's
observed results: formula (clone/eq/walk/map/adjust at 4,096), calc
(401 and 4,096 operands, -, *, /, ^, &, %, comparisons, 1,500 refs),
xlsx (long chains written as scalar formulas), engine (cell.set, fill,
copy/paste, insert rows/columns, undo/redo, Evaluate Formula, XLSX save
and reopen), Spanish entry. Each fails without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheet names starting with '.' or reading as R1C1 (R, C, RC, R1C1...) were saved
unquoted, so Excel could not open the file; PivotTable sources and ADDRESS left
cell-like names (A1) bare. The quoting rule, unquoting and sheet splitting now
live once in gridcraft_core::addr and replace the copies in the formula printer,
pivot, ADDRESS, chart, xlsx, Solver, Go To and the Name Box (which now reads ''
as ').
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lower-casing then upper-casing made dotless ı equal to i (both become I),
which Unicode's default case folding keeps apart; LET(i,1,ı,2,i) could
return the ı binding. same_name now lower-cases and adds the folds lower
case misses (ß/ẞ -> ss, ſ -> s, ς -> σ). Tests cover ı/i and the Greek
final sigma.
app.language.set's engine.commands docs and its unknown-language error
were written by hand and had fallen behind: the docs missed es, fr and
sr, the error missed de, fr and sr. Both are now built from
Language::ALL (Language::code_list), and tests check every language is
listed in each.
iter_range walks the sparse store's bands for the area's row range
only, so a chain of N formulas no longer rescans every stored cell of
the sheet per area.
Co-Authored-By: Claude <noreply@anthropic.com>
- gridcraft_formula::same_name lower-cases then upper-cases each
character, so ß matches ẞ (to_uppercase alone maps ß to SS).
- Formula-bar input uses it too, so a binding such as ſi is not
translated to IF while the evaluator binds it.
- Tests: ISOMITTED with a case variant, ß/ẞ, and the Spanish input case.
- The region already starts at the header row when headers are on, so
the dropdown label reads start.row for both cases (was showing the
first data value, e.g. B: 30 instead of B: Revenue).
- + Add Level picks the first region column no level uses yet instead
of columns.get(levels.len()), which re-seeded an already-used column.
Co-Authored-By: Claude <noreply@anthropic.com>
Excel matches LET and LAMBDA names case-insensitively in every script,
so =LET(dług,100,DŁUG*2) is 200. The evaluator compared them ASCII-only
and returned #NAME?. The three lookups (name evaluation, ISOMITTED, a
LET variable shadowing a function name) now fold Unicode case.
The outline glides 0.12 s toward each new selection. While selecting with the mouse that makes
it trail the pointer, which reads as lag (the grid itself paints in under a millisecond per
frame). With a mouse button held it is now drawn on its cells at once, as in Excel; keyboard
moves still glide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: MH <q6rrzz@gmail.com>
A formula at the nesting limit reading many uncalculated formulas (for
example SUM over a wide range) suspended the abandoned evaluations once per
cell read, restarting them each time. The other formulas read at the limit
are now collected and calculated together with the suspended one, before the
abandoned evaluations restart. Results, circular-reference and iteration
behaviour are unchanged. Adds a regression test (3000 suspensions before, 1
after).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The deref doc comment still said blocks are trimmed to the used range. Describe
the actual rule: blocks up to FULL_SIZE_CELLS, or up to FULL_SIZE_BOUNDED_CELLS
when they do not run to the sheet's edge, keep their full shape; the rest stop
at the used range. Docs only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`cargo xtask ci` has not passed on macOS or Windows since cc21643:
error: this let-binding has unit value
--> crates/ui-egui/src/desktop_theme.rs:36:13
36 | let mut link = Link::default();
`Link` is the session-bus connection the watcher keeps between reads. Off Linux
there is no bus to keep, so it was aliased to `()`, which makes that binding a
unit binding: `clippy::let_unit_value` reports it and the gate's `-D warnings`
turns it into an error. CI runs Linux only, where `Link` is a real
`Option<Connection>`, so nothing catches it there.
Off Linux `Link` is now a unit struct rather than `()`. The binding has a type,
the lint does not apply, and nothing else changes: `read` still ignores it on
those platforms. An `allow` on the line would also work, but a named type says
what the thing is rather than silencing a lint that only fires on some
platforms.
`SheetProtection` carries eleven permissions. `format_cells` is checked in
`apply_style`, and an edit to a locked cell is caught after the fact by
`check_protection`, but `sort` and `autofilter` had no reader anywhere outside
`sheet_read.rs`, `sheet_write.rs` and a round-trip test: they only ever
travelled through the file. So a protected sheet could still be sorted, and
Ctrl+Shift+L still put an AutoFilter on it.
`refuse_unless_allowed` sits beside `locked_cell_error`, with three call sites:
`do_sort` (which both Sort… and the A-to-Z buttons come through), `toggle_filter`
and `filter_by`.
Where the box decides is worth knowing. Sorting a range of locked cells was
already refused, by the locked-cell guard rather than the permission, because a
sort moves values between locked cells — which is Excel's rule too ("users
cannot sort ranges that contain locked cells on a protected worksheet,
regardless of this setting"). So the Sort box decides for a range that has been
unlocked, while an AutoFilter changes no cell's contents and the box is the only
thing that ever decided it. The test asserts both, and that an unprotected sheet
still sorts and filters.
Not fixed here: Delete Rows and Delete Columns are refused even when their boxes
are ticked, because deleting removes locked cells and trips the same post-hoc
guard, while Insert Rows and Insert Columns work. Letting the guard know which
operation ran is a change to code every command passes through and wants its own
PR.
It returned an empty string for every workbook, saved or not, so the classic
sheet-name formula `MID(CELL("filename",A1),FIND("]",…)+1,31)` gave #VALUE!:
with no `]` in the string there is nothing for FIND to find. Excel returns
`<folder>\[<file>]<sheet>`, and empty only before the first save.
The model had no file to report. `source_dir` could not serve: it names the
folder relative links to other workbooks are relative to, and must keep naming
it after a Save As into another folder, because the links held in memory are
still written for the old one — only the saved copy is rebased. `source_path`
is therefore its own field, with that distinction written down.
It is set when a workbook is opened, from the path made absolute so the folder
is the one the user sees, and when a full-fidelity save makes a file the
document's own — before the `saved` snapshot, or the workbook would read as
dirty the moment it was saved, and only when it changes, so an ordinary Save
does not clone the workbook for nothing. A workbook that arrived as bytes knows
its name but never a folder and reports `[name]Sheet`, which still splits on the
`]`; an import reports the file it came from, as Excel does. The sheet is the one
the reference is on, not the active one.
CELL is volatile and already in the volatile sets, so a cell holding it answers
from the next recalculation; after a Save As that means F9 rather than the save
itself, which the engine test asserts rather than papers over. Making a save
recalculate is a larger decision than this issue.
`check` still skips CELL("filename") as volatile, as docs/cli.md says: the
cached value in a file is the folder of the machine that saved it.
The Sort dialog's 'Sort by' box was a free-text field hard-coded to
'A', so sorting a region in column B silently sorted column A unless
the user noticed and retyped the letter. It now seeds from the column
the cursor is in and renders a dropdown of the current region's
columns, labelled with their header text. '+ Add Level' seeds the
next region column instead of a hard-coded B.
Closes#1906
Co-Authored-By: Claude <noreply@anthropic.com>
desired_rows(1) only sets the field's minimum height; it grows with its
text. Pins that an Alt+Enter edit in a cell without Wrap Text shows all of
its lines and reserves no more. Review feedback on #1974.
Bound the all-level walk to the sheet sparse cell store instead of
iterating every position of each precedent area: formulas late in a
wide area are no longer skipped, blank positions are never selected,
and only formula cells extend the frontier. Adds a regression test
(=SUM(A1:Z1) with a formula at Z1) and silences two clippy lints in
the same function.
Co-Authored-By: Claude <noreply@anthropic.com>
With Frame::NONE the text field takes no text margin off its width, so it
wraps at the editor's width less the 2 px inset a side. Measuring 8 px
narrower counted rows the field doesn't have and made the editor reach
over the cells below. A test pins the field's wrap width. Review feedback
on #1974.
Adds formulas.selectPrecedents / formulas.selectDependents engine commands
and binds them to Ctrl+[ and Ctrl+] (Ctrl+Shift walks every level), matching
Excel's shortcut parity. Direct-level traces keep whole precedent ranges;
cross-sheet precedents jump to the owning sheet; an empty trace leaves the
selection untouched with an actionable message.
A request that waited more than 60 s was answered with a timeout and left in
the queue, so `control::poll` ran it on the next UI pass and dropped the reply.
The client was told its command had failed while the command was applied: an
agent that retried after the error ran it twice, and one that gave up left an
unexpected change in the workbook.
`ControlRequest` now carries who has it -- pending, taken by the app, or
abandoned by its sender -- as the report suggests. `poll` claims each request
and skips one it cannot claim; the connection thread abandons the request when
its reply times out, and says which happened:
still queued timed out waiting for the UI (is the window minimised?)
already started timed out waiting for the UI, which had already started the
request, so it may still finish; its outcome is unknown
The second case cannot be called a failure without guessing. This is the shape
DeckCraft settled on for the same bug (its #719), so the two apps now agree.
Fix: #1914
`C1#` gave `#REF!` while the dynamic-array formula in C1 happened to return a
single value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so
`ROWS(C1#)` and `SUM(C1#)` failed and `COUNT(C1#)` gave 0. Every formula that
counts or sums a filtered list therefore broke the moment the list had exactly
one entry. In Excel the formula is the anchor of a 1 x 1 range and the three
give 1, the value, and 1.
A pass recorded the spill only for an array larger than one cell:
if a.rows > 1 || a.cols > 1 {
self.spills.insert(k, a.clone());
}
so a 1 x 1 result left no range for `spill_range` to answer with, and `eval`
turned the missing range into `#REF!`. It is recorded unconditionally now.
The report suggested keying this on the cell carrying `cm`, which turned out not
to be expressible: nothing in the model says "dynamic array" positively -- the
reader tells one from a legacy CSE formula only by leaving `Formula::array`
unset. The arm itself is the better test, because a formula whose result is a
scalar never reaches it: `=1+1` does not become an anchor, which a test pins.
It also matches Excel 365, where `=A1:A1` is a 1 x 1 anchor of its own.
gaps.md §6 still listed four things as missing and function-parity.md still
marked 15-significant-digit comparison as missing, citing #57 — all five
reports are closed and their PRs merged. The work list reads as the place to
pick work from, so a closed entry sends someone after work that is done.
Checked by behaviour, not by issue state, where that was possible:
=0.1+0.2=0.3 -> TRUE
=(0.1+0.2)-0.3 -> 5.55E-17 (full precision kept,
as in Excel: the 15-digit rule
is for comparison)
=MATCH(0.3,{0.30000000000000004},0) -> 1
=COUNTIF({0.30000000000000004},0.3) -> 1
a spill blocked and then unblocked -> #SPILL! then the spill again,
with its dependent back to 6
and by their regression tests for the other two:
`deleting_a_3d_span_end_sheet_keeps_the_rest` (#140) and
`sheet_operations_keep_names_on_their_sheets` (#134).
§6 is left open rather than deleted: it says wrong numbers are the worst kind of
bug, and five closed reports are not the same as the category being clear —
that needs §15's oracle suite. The function counts are untouched; this is
formula-language semantics, not the inventory.
Templates hide grouped columns as width="0" hidden="1". Show Detail and the level buttons only cleared hidden, so the columns stayed invisible; they now reset a zero size, as Unhide does.
The in-cell editor ignored Wrap Text: it widened to the text's one-line
width, up to the grid's right edge, so a long wrapped cell became one
unmanageable line while edited. With Wrap Text on it now keeps the
column's width, wraps, and grows down to show every line, as Excel's does.
The multiline field is one row tall at least, not four, so a short edit
doesn't reach over the rows below.
### Summary
This change centralizes the translation of user-facing error messages in the UI layer while preserving the original error payloads for machine consumption and AI-agent integrations.
### What changed
- Added a centralized error-translation path in `i18n.rs`.
- The UI now translates only the final display text, instead of altering the underlying engine or protocol error payload.
- If a translated string is not available, the original message is kept as a fallback, avoiding broken or overly-simplified output.
- The translation logic is applied at the presentation layer, not at the source of the error.
### Updated call sites
- Error and alert rendering in `widgets.rs`
- Dialog error presentation in `dialogs.rs`
- Print and feedback failures in `printing.rs`
- Top-level UI error handling in `lib.rs`
### Design intent
- Keep the raw engine errors intact for scripts, control channels, and AI/agent integrations.
- Present a clearer, localized message only to end users.
- Avoid modifying dictionary files or adding new translation entries to the global catalog unnecessarily.
- Keep the behavior consistent with the project’s accessibility and internationalization goals.
### Validation
- The focused i18n test passed successfully.
- The relevant UI test suite passed successfully.
- The repository-required CI gate was run and completed without failures according to the latest verification output.
The symbol was interpolated with Rust's Debug formatting, which escapes a
control character into literal `\n`-style text. It is now written into the
format's quoted literal directly, and a symbol holding a control character is
refused along with a quote or a backslash, so nothing is escaped behind the
user's back.
Test: `accounting_format_takes_the_symbol_it_is_given` covers `\n` and a tab.
`cargo xtask ci`: 8/8 steps, 0 failures.
`home.accounting` documents `{range?, symbol?}` but ignored the symbol: the
command always applied the `$` format, so a script or MCP client asking for
lempiras (#1893) or any other currency silently got dollars. The symbol is now
part of the code it writes, and a symbol that would break the format string (a
quote or a backslash) is refused instead of being pasted in. The default is
unchanged, character for character.
Tests: `accounting_format_takes_the_symbol_it_is_given` (displayed text for `L`
and the default, and the two refused symbols) and
`the_default_symbol_reproduces_the_accounting_format` (the built code still
equals the named `Accounting` format). `cargo xtask ci`: 8/8 steps, 0 failures.
range.fill parsed the input once at the active cell, then put back the old
style of every target that already held a cell, so 5%, $5, 3/4/2026 or
=DATE(...) lost their automatic format there (0.05, 5, 46085), and empty
targets took the active cell's style. Each target now gets what
input_to_cell gives for it (parsed once per distinct style), as typing does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI installs the stable toolchain, and Rust 1.95's clippy fails
'cargo clippy --workspace --all-targets -- -D warnings' on main:
- xls/mod.rs: collapsible_match. The XF record's size check becomes a
match guard; a full table still ignores the record (the catch-all arm).
- passthrough_tests.rs: nonminimal_bool. !is_some_and(x) is written as
is_none_or(!x).
No change in behaviour.
Signed-off-by: MH <q6rrzz@gmail.com>
With whole rows or whole columns selected, Excel inserts or deletes them
directly; it asks which way to shift only for other selections. Both
shortcuts always opened the Insert or Delete dialog, an extra step for
the most common case.
Signed-off-by: MH <q6rrzz@gmail.com>
At high zoom the heading labels stopped at 16 points (the outline levels
at 14) while the cells kept growing, so at 300-400% the letters and
numbers looked tiny in their tall headings. Excel scales them with the
zoom. The labels now scale without a cap and keep their readable floor
at low zoom (heading_font_size).
Signed-off-by: MH <q6rrzz@gmail.com>
Cells are painted left to right and a right-to-left sheet is mirrored
afterwards, so an explicit Align Right ended up on the left (and Align
Left on the right). Excel keeps an explicit alignment on the side it
names in either sheet direction; General alignment follows the sheet.
The alignment is now swapped before painting (painted_halign), for
straight and rotated text alike.
Signed-off-by: MH <q6rrzz@gmail.com>
From CodeRabbit's review of #1892: the fix also strips the prefix from
"Sheet Options|View" (the Gridlines and Headings checkboxes), but the test
only rendered File › Options. It now opens Page Layout in French and
Serbian too and checks that the checkboxes read "Affichage" / "Prikaz",
not "Options de la feuille|Affichage" / "Opcije lista|Prikaz". It fails
with the old catalog values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Undo stored the selection at the moment of undoing (wherever the user had
clicked) in the redo entry, so redo jumped back there instead of to the
cells it changed. The redo entry now keeps the undone step's selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backspace clears the cell (edit.clearContents) and opens an empty editor.
Committing that editor ran cell.set "" on the already-empty cell, which
recorded a second, empty undo step, so Ctrl+Z appeared to do nothing and
the next Ctrl+Z undid an unrelated earlier edit. Committing empty text
into an empty cell now runs no command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alt+H, O, A (AutoFit Row Height) and Alt+H, O, I (AutoFit Column Width)
ran nothing when Alt was still held for H, as Excel allows: any key with
Alt down cancelled keytips. KeyTips now tracks the left Alt key, and a
letter typed with it held that continues a known sequence is handled as
a keytip letter. Other Alt combinations still cancel; AltGr (right Alt)
and macOS Option are unchanged (#1864).
fr.rs and sr.rs translated three `context|label` keys with the context
kept ("Options|Général", "Opcije|Opšte", "Options de la feuille|Affichage"
...). `Language::tr` shows a found translation as it is, so File › Options
section headings and the Sheet Options "View" checkboxes showed the prefix.
The values now hold only the label.
Tests: `translations_never_show_the_context_prefix` (no catalog shows a `|`
for any key) and `translated_options_headings_show_no_context_prefix`
(File › Options in French and Serbian); both fail on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the token check on the first line and the new bounded-line request parsing;
tests authenticate before sending their requests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of
native stack, so the depth it reached depended on frame sizes. On a
Windows release build the deepest countdown was F(F,94), where 0.7.0
reached F(F,131).
The evaluator now counts the LAMBDA calls a formula has in progress, in
call_lambda_values, which direct calls, defined names, MAP, REDUCE,
SCAN, BYROW, BYCOL and MAKEARRAY all go through. The 141st call answers
#NUM!. The countdown F(F,131), the deepest that the 400-level MAX_DEPTH
allows, has 132 calls in progress and still runs.
The stack check stays as a guard. Its budget rises to 896 KiB, which
leaves 128 KiB of a 1 MiB thread for the frames above the formula.
a_recursive_lambda_stops_at_the_same_call_on_any_stack_size runs
recursions on each side of the limit on a 1 MiB and a 64 MiB thread and
expects the same results. The 1 MiB test now runs the countdown
F(F,131).
rowGrandTotals is the Grand Total row under the report and colGrandTotals the
Grand Total column at its right. Both were mapped the other way round; a
GridCraft round trip hid it, but an Excel file with only one grand total opened
with the other one.
Found while testing crates/engine/src/cmd/data.rs:550 (custom_ok, used from apply_filter at :623)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SheetApp gains a non-persisted `chrome` field. Both parts stay on by
default; a host that owns its window and files can hide the title bar,
and the File tab together with the Backstage view it opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
file.open takes {csvAsText: true}: a CSV, TSV or TXT file opens with every field as the text
it holds, with no number, date or boolean conversion, so identifiers such as GTINs, SKUs and
ZIP codes keep their digits and leading zeros and save back unchanged. The default is
unchanged. The CsvOptions.parse_values switch already existed in the reader.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
While pointing at another sheet, a reference without a sheet name (A1) was outlined on the
sheet being shown instead of the sheet the edit started on.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fixed: On macOS, Cmd-click sets mods.command, not mods.ctrl. Both calls therefore pass false for add, so point_header replaces the reference instead of adding a column or row union. Pass mods.command, as the adjacent selection path does. egui defines command as Cmd on macOS and Ctrl elsewhere. (docs.rs)
&
Fixed: Point mode remains active when the user changes sheets. The extension closure combines whole-column or whole-row references by coordinates only and discards the previous reference’s sheet. A prior Sheet2!C:C reference can therefore be combined with column E on Sheet3, then emitted as Sheet3!C:E.
Compare the previous reference’s effective sheet with the active sheet before extending it. If they differ, use only the clicked range.
Excel counts TRUE/FALSE and error values as strings in a cache field's
<sharedItems>. GridCraft wrote containsString="0" (and counted booleans and
errors as separate kinds for containsMixedTypes) for such columns, which made
the file unopenable in Excel, even when the column wasn't used in the pivot.
Add Stats::strings() (text, booleans, errors) and use it for
containsSemiMixedTypes, containsString and containsMixedTypes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid's look() and print's cell_look() layered a rule's fill, font and
borders onto the cell style but not its num_fmt, so a cell under a rule with a
number format kept its own. Take the rule's code when it isn't General (in a
differential style General means "not set", as layer_under treats it).
Print also re-formatted every non-General cell from its own style through
display::cell_text; format with the look's code instead, through a new
display::value_text_as that value_text now calls (CSV/Text output unchanged).
Show Formulas keeps cell_text.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Crostini's cros-host-fonts mounts the host's fonts under
/usr/share/fonts/chromeos (noto/, notocjk/, crosextra/), which none of the
fixed Linux path lists reached: Thai, Hebrew, CJK and Symbols 2 fell back to
egui's Latin-only faces and drew tofu with the fonts installed, and the UI and
cell roles missed Noto Sans and Carlito. Add those folders to each list, with a
structural test that every list probes them.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The evaluator stopped at MAX_DEPTH (400) nested eval calls. A LAMBDA
call takes one of those levels but several stack frames (eval,
eval_inner, call, builtin, apply_lambda, call_lambda_values), so on the
1 MiB main thread of the Windows build
`=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))` overflowed the stack before it
reached the limit, and the process ended.
eval now also stops once a formula has used 512 KiB of stack below its
first eval. Both limits answer #NUM!, the error Excel documents for too
many recursive calls, and a defined name that holds the recursion passes
that error on instead of answering #NAME?.
Fixes#1662
Adds Language::Vi (vi), following the per-language module pattern (crates/ui-egui/src/i18n/<lang>.rs). All 398 strings in the master table are covered, checked by the completeness test; vi-VN locale tags resolve to Vietnamese.
egui's bundled fonts have no Lao, and none of the script fallback faces
(CJK, Thai, Hebrew, symbols) carry it on macOS, so Lao text in a cell,
the formula bar or the interface drew as boxes unless the cell's own font
had Lao glyphs. Add a `sys-lao` fallback: Leelawadee UI / DokChampa on
Windows, Lao Sangam MN / Lao MN on macOS, Noto Sans Lao / Phetsarath OT
on Linux. `fallback_covers_its_script` now checks a Lao sample.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sheet tab context menu buttons (Insert Sheet, Rename, Move or
Copy…, Duplicate, Tab Color, Hide, Unhide…, Protect/Unprotect Sheet,
Select All Sheets), the navigation tooltips (Previous/Next/All sheets,
New sheet), and the AutoSave toggle label and tooltip in the ribbon were
all hardcoded English — they were never routed through lang.tr().
Fix:
- tabs.rs: wrap every hardcoded UI string with app.ui.language.tr(…)
- ribbon.rs: wrap the AutoSave label and hover text with lang.tr(…)
- i18n/cs,de,es,fr,ko,pt,ru,sr,zh: add the 130 missing translation
entries (each language only gets the entries it was missing)
Translations produced from first-principles per language, following the
terminology used for other sheet/workbook strings already in each file.
Closes#1537
Found while testing crates/engine/src/cmd/sheet.rs:216-238 (rewrite never updates Chart.source); used by crates/engine/src/cmd/insert.rs:849 (chart_switch)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
forecast_grid re-added every squared residual in each window, O(n×window).
Keep a running sum instead: add the entering term, subtract the leaving one,
and restart at a point without a forecast so windows touching it stay #N/A.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/sheet.rs:115 (shift_sheet_features; it never touches sh.pivots)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
table.resize has existed in the engine since #589/#1706 (overlap checks, tests), and the
command catalog already promised it under Table Design > Properties, but no ribbon button ever
called it -- the only way to resize a table was scripting the command directly (control channel,
CLI, MCP). #935 (no drag handle) assumed this menu path worked; it doesn't.
Adds a "Resize Table" button next to the Table Name field (same Properties group the catalog
already declares), opening a small dialog pre-filled with the table's current range. Confirming
runs table.resize with the typed range, same validation as #589/#1706 (rejects overlap with
another table, matching Excel's "A table can't overlap another table.").
The drag-handle part of #935 is unaddressed; this only restores the menu/dialog path.
Verified manually: created two tables, used the new button to grow one onto the other (rejected),
then to an adjacent non-overlapping range (accepted, table intact).
auto_row_height estimated a line of a bigger font by scaling the sheet's
default row height by size / 11. When the sheet's default already fits
that font, the font counted twice: Excel for Mac saves rows of 16pt text
with defaultRowHeight="21" (28px) and no ht, and opening such a file
fitted every row to 40.7px, 1.45 times Excel's height. Rows mixing Middle
and Bottom aligned cells then looked misaligned.
The estimate now scales an 11pt row's height (DEFAULT_ROW_HEIGHT), so
those rows open at 29px, and rows on a 15pt-default sheet still grow to
their font (#833).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/extra.rs:818-855 (table_resize, overlap check at ~832)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fedora keeps each font family in its own package folder, and the cell, serif, mono, Thai and
Hebrew candidates only listed the Debian/Ubuntu/Arch folders, so no cell face was found there and
bold and italic cells drew as regular text in the built-in font. Add Fedora's folders after the
existing Linux entries for each font, as #54 did for the UI fonts, and let mono fall back to
Liberation Mono and then Fedora's default Noto Sans Mono, since DejaVu Sans Mono is optional there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workbook GridCraft created had no <fileVersion> in workbook.xml; it now
names GridCraft, and keeps it on later saves.
A workbook read from an Excel file still keeps its <fileVersion> as it was,
appName="xl" included. Observed in Excel 16.114: with the calcFeatures
extension present, Excel uses the saved cell values only when appName is
"xl". appName="gridcraft", "Calc" or no appName makes it recalculate
everything on open, which brings back #799 (lookups into closed workbooks
open as #CALC!). The application that last saved the file is recorded in
docProps/app.xml <Application>GridCraft</Application>, which GridCraft
already writes on every save and which Excel's recalculation ignores.
Checked black-box in Excel for Windows (Microsoft 365) with synthetic
workbooks: a bare name in <c:f> draws the whole chart blank, without a
repair prompt, for workbook- and sheet-scoped names alike, while
`[0]!Name` and `Sheet1!Name` plot.
- Write: a sheet-scoped name is written `Sheet!Name` (quoted when
needed) instead of bare; an already qualified name keeps its sheet;
an undefined name gets `[0]!`. A reference to an unknown sheet is
kept as written. A qualified series name is written as a strRef.
- Resolve: a series name `Sheet1!Name` is looked up on that sheet, as
the evaluator does, instead of being shown as literal text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel writes a workbook-scoped defined name in a chart reference as
`[0]!Name`. The reader kept that text, the lexer took `[0]` for a
structured reference, and the series evaluated to #NAME?: a chart over
dynamic (OFFSET) names opened blank with one #NAME? category.
- Read: `[0]!Name` becomes `Name` in c:f (cat, val, xVal, yVal,
bubbleSize, tx), chartex cx:f and _xlchart names. External books
([1]Sheet1!...), quoted sheet names and strings are untouched.
- Write: a bare workbook-scoped name is written back as `[0]!Name`; a
sheet-scoped name stays bare. A series name that is a defined name is
written as a strRef instead of literal text.
- Resolve: a series name that is a defined name is evaluated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line that is not a JSON object with a string method, including an HTTP
request line, bytes that are not UTF-8 or a line over 64 MiB, gets one
error reply and the connection closes before anything after it is read.
Request lines are read with a bound instead of buffering them whole.
Floating pictures were decoded at full size, with no limits, into one
process-wide map of textures that never let go, not even when their
workbook closed. Its key was the picture's id and byte length, and ids
are per workbook, so a second open workbook could show the first one's
picture.
The new floating_pictures module, built like cell_pictures, keeps a
cache per egui context keyed by workbook, picture id and size:
- bounded at 128 MB, least recently drawn first, never evicting a
picture drawn in the last frame, so none turns into a placeholder;
- a workbook's textures go when it closes (checked every frame);
- a picture's header is read first (16,384 px a side, 64 MP at most),
it is decoded with limits, and to the next size step of what is
drawn (a power of two or three quarters of one, up to 4,096 px or the
GPU's limit); it is decoded again only when drawn larger;
- a picture that can't be decoded is remembered, not retried each frame.
Measured on macOS 27 (Apple M3 Max): 12 pictures of 2600 x 2600 opened
from an .xlsx and scrolled through, textures held by egui 258 -> 40 MB,
settled footprint 738 -> 509 MB; after closing the workbook, textures
258 -> 0 MB, footprint 504 -> 236 MB. 24 pictures: textures 490 -> 76 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every edit copies the workbook for its undo step (copy-on-write: sheets
and 64-row bands of cells are shared). A floating picture's bytes
(Image.data) and the VBA project (Workbook.vba_project) were plain
Vec<u8>, so each edit on a sheet with a picture, and every edit in a
workbook with macros, kept another full copy of them, up to the 100
steps of the history. They are Arc<Vec<u8>> now, as cell pictures
already are, with base64 helpers for serde (b64::shared, b64::opt). JSON
and XLSX are unchanged.
Measured on macOS 27 (Apple M3 Max), gridcraft-cli, kernel peak
footprint: a 19 MB picture and 50 cell edits on its sheet, 992 -> 22 MB;
10 edits, 215 -> 21 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui copies an owned font's bytes into its text engine (epaint's
blob_from_font_data clones the Cow) and keeps the original FontData too,
so every interface and script fallback font was in memory twice: about
100 MB on a Mac, where the fallbacks alone (Hiragino Sans GB, Hiragino
Sans, Apple SD Gothic Neo, Thonburi...) are 89 MB of files. try_load now
keeps each file it reads for the rest of the run and hands egui borrowed
'static bytes, which it shares. These faces are loaded by every font
rebuild anyway, so keeping them costs nothing, and rebuilds no longer
read them from disk again. A missing or broken file is looked for again
next time. Which faces load, and their order, are unchanged; workbook
fonts (bind_faces) load as before.
Measured on macOS 27 (Apple M3 Max), offscreen snapshot held after its
picture, settled footprint: empty workbook 258 -> 158 MB, the sample
workbook 261 -> 160 MB, a cell in five scripts 258 -> 158 MB; the
rendered pictures are byte-for-byte the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/extra.rs:664 (command doc at extra.rs:114)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The writer gave every drawing its own xl/media/imageN part even when the pictures
had the same bytes, so a workbook with one logo on 16 sheets grew 16 copies of
it on a plain open and save. Out::image_part now returns the existing part when
an identical picture was already written (same extension and bytes), and every
drawing's relationship points to it, as Excel stores a copied picture.
A hostile file could abort any process that opens it: a cell formula of a
million `%` (`1%%%…`) or a 200,000-term sum parsed into trees deep enough to
overflow the stack when printed, saved, evaluated or dropped, even in release
builds. Postfix operators (`%`, invoking a result) built their nesting in a
loop that the 256-level limit did not see.
- `parse` refuses formula text longer than Excel's 8,192 characters
(`MAX_FORMULA_LEN`, counted in characters, the leading `=` excluded). This
also bounds the depth of the left-deep trees long operator chains build.
- Each postfix `%` and each call of a result counts towards the nesting limit.
- A file's formula that is refused keeps its text and cached value, as other
unparsable formulas do.
Regression tests: formula (small-stack thread: long `%`, invoke and operator
chains are errors; the limit counts characters, not bytes) and xlsx
(`hostile_formula_text_does_not_overflow_the_stack`: open, save and drop a
workbook with such formulas and a defined name on a 1 MiB stack). Both abort
with a stack overflow without the fix.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/data.rs:826
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/core/src/parse.rs:~170 (parse_time, parts parsed with f64::parse)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
do_sort moved cells, formulas, cell pictures, row heights and floating
object anchors through the sort permutation, and remove_duplicates moved
cells and pictures through the compaction, but neither touched
Sheet::comments or Sheet::hyperlinks (keyed by CellRef): a note that
described one record stayed behind and annotated whatever landed on that
row — 'late payment' on a paid invoice — and a note on a row removed by
Remove Duplicates annotated blank cells. Sheet::comments/hyperlinks are
exactly the range-scoped side data that row and column inserts already
remap and edit.clear already clears, so the omission looks unintended.
Both maps now travel through the same permutation the cells use (the
sort collects before re-inserting, so a swap cannot resurrect an entry
from a destination that is also a source; Remove Duplicates compacts the
kept rows and drops the rest).
Regression test: sort_and_remove_duplicates_move_the_rows_notes_and_links
— on main the note and link stay on their old cells; with the fix they
travel with their record.
A cut removed the source's cells and pictures and then re-created the
block's merged areas at the destination and copied its notes there — but
removed nothing from the source, so one merged block became two (an
empty one left behind) and a note ended up on both the old and the new
cell. Excel moves both and leaves the source clean.
The cut now clears the source range's comments, hyperlinks and merges
the same way edit.clear does. A second gap in the same path: paste never
transferred hyperlinks at all, so clearing the source would have lost
them — paste now carries hyperlinks (in What::All and a new
What::Hyperlinks, so edit.pasteSpecial {"what":"hyperlinks"} works
like "comments").
Regression test: a_cut_moves_the_sources_notes_hyperlinks_and_merges —
on main the old merge stays behind and the note is on both cells; with
the fix the merge, note and link all move and the source is clean.
The engine's live filter state is always sheet.autofilter — apply_filter,
filter_values, clear_filter, reapply and the slicer command all read and
write it, and a table's header dropdown installs one over the table's own
range — while Table::filter is only ever filled by the reader. Writing
suppressed the sheet <autoFilter> whenever any table merely intersected
it and wrote the table part from Table::filter, which was empty: a
filtered table saved with its rows still hidden and no criteria anywhere,
so on reopen there was no filter to clear and Excel showed an inactive
filter over mysteriously hidden rows. A slicer's selection was lost the
same way.
Fold the coinciding sheet filter's criteria into the table part (colId
offsets are relative to the range's first column, and both ranges start
at the same column, so they carry over verbatim); a filter that only
partially covers a table still has nowhere valid to go and keeps being
suppressed.
Regression test: a_table_filter_criteria_survive_a_round_trip — on main
the table part carries no criteria after a save; with the fold they round
trip, the hidden row stays, and the sheet keeps no duplicate autoFilter.
print_titles read both rows and cols as Options and assigned both
unconditionally, so {"rows":"2:2"} set title_cols = None and
{"cols":"A:A"} set title_rows = None: the repeated rows or columns
disappeared from every printed page with no message, and there was no
way to set or clear just one axis. The sibling pageLayout.pageSetup
already distinguishes "key absent" from "key present but empty"; read
both axes the same way — absent leaves the axis alone, empty/null clears
it, anything else that is not a range is an error rather than a silent
wipe.
Regression test: print_titles_keeps_the_axis_it_is_not_given — on main
the rows-only call clears the columns; with the fix each axis is touched
only when named, an explicit empty value still clears, and a bad range
errors.
weekend_mask rejected the all-weekend string outright for both
NETWORKDAYS.INTL and WORKDAY.INTL, but Microsoft documents them as
differing: NETWORKDAYS.INTL "Using 1111111 will always return 0",
WORKDAY.INTL "1111111 is an invalid string". The crate already
implements that split — workday_core's per_week == 0 guard — which was
unreachable dead code while the extra clause stood. Drop the clause:
NETWORKDAYS.INTL counts zero workdays as documented and WORKDAY.INTL
errors through the guard it already has.
The existing test asserted the old behaviour and is updated.
Regression: working_days — on main the NETWORKDAYS.INTL assertion fails
(#VALUE! where Excel returns 0); with the clause dropped it passes and
WORKDAY.INTL is asserted to error.
Found while testing crates/xlsx/src/csv.rs:204 (write_csv_limited, starts at sheet.used_range().start instead of A1)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
egui's default quit shortcut closed the window without asking to save.
Clear it off macOS (Cmd+Q stays the app's Quit there) and add a regression test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The argument hint, autocomplete, Insert Function and the Formulas-tab menus
read per-language tables of function descriptions and argument names
(i18n/<lang>_functions.rs), keyed by the catalog's English text and
binary-searched. The hint is assembled from the formula syntax's function
name and separator plus the language's argument names, all of them or none,
so it never mixes two languages. Insert Function searches the translated
descriptions and shows readable category names. SPANISH_HELP moves to
es_functions.rs with its 10 functions.
INDEX(A1:C3,2) returned the whole second row. Excel gives #REF! when a
reference has several rows and columns and the column argument is
omitted, so the error also carries into ranges built from it
(A1:INDEX(A1:C3,2)), position arrays (INDEX(A1:C3,{1;2})) and outer
functions. An empty column argument (INDEX(A1:C3,2,)) still gives the
row, and arrays are unchanged: INDEX({1,2;3,4},2) is the row.
Observed in Excel that a column array with one index,
INDEX({10;20;30},2), is a 1x1 array (TYPE 64), which is what calc
already returned; the new test pins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edit.autoFill capped source and target at 5M cells separately, but fill()
writes one cell per source lane per step, and it never checked the target
held the source. Whole rows into whole columns (`2:3` into `A:A`) filled
every one of 16,384 columns down a million rows: ~17 billion writes, then
a 24-51 GB allocation abort that catch_unwind can't stop.
Excel (observed via COM on a synthetic workbook) refuses AutoFill when the
target doesn't contain the source or holds it away from every corner, and
accepts any corner-sharing target. edit.autoFill now applies that rule
(fill::holds_source_in_corner), and fill() bounds lanes x steps by
MAX_FILL_CELLS as a backstop. The 5M literals in auto_fill and fill_dir
use the shared constant.
Regression test: fill::tests::autofill_refuses_targets_that_do_not_extend_the_source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Larghezza predefinita, Cancella regole dall'intero foglio, Foglio previsione,
Giorno feriale, Singola/Doppia contabilità, Statistiche, Layout rapporto,
and the Space key name in the command search hint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OK runs a dialog's command with build_params(&d), which reshapes the
field values for several dialogs (the rule wrapper of cfQuick, the
delimiters list of Text to Columns, the columns of Remove Duplicates and
Subtotal, Data Validation's unused fields, Zoom presets, Series, Define
Name). ui.dialog.confirm passed the raw field values instead, so a
Highlight Cells rule confirmed over the control channel added nothing
and Text to Columns ignored the ticked delimiter.
Make build_params pub(crate) and use it from ui.dialog.confirm, say so
in the control protocol doc, and add tests driving the Greater Than and
Text to Columns dialogs over the control channel.
In Excel the "contains", "begins with" and "ends with" rules are
SEARCH/LEFT/RIGHT formulas, which fail on an error value, so an error
cell is never formatted by them and always matches "does not contain".
GridCraft matched on the error's display text instead, so #N/A
"contained" a and "began with" #.
Skip error values in the three positive rules and match them in
notContainsText, with a regression test over =NA(), =1/0 and text cells.
Adds Language::It ("it", native name "Italiano") with a new per-language
module crates/ui-egui/src/i18n/it.rs, following the existing pattern
(translate(text) -> Option<&'static str>). The catalog has 971 entries:
every key the most complete catalog (pt) translates, plus the PivotTable
Fields pane strings (from es) and Options|Editing. Labels follow Excel's
Italian terminology (Foglio, Cartella di lavoro, Blocca riquadri, Unisci e
allinea al centro, Somma automatica...). Function names are not touched.
Also: it / it-IT / it_CH tags resolve to Italian (system-language default),
Language::ALL and the coverage/tag/age tests include Italian, and
docs/localization-parity.md lists the new language.
Not compiled locally (no Rust toolchain on the author's machine); relying on CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepare report data before editing so refused reports cannot leave solution cells or the saved model changed. Commit accepted reports with the optional solution and model, retaining array/protection checks and row autofit after sheet insertion.
Closes#1050
Co-Authored-By: OpenAI Codex <noreply@openai.com>
The Formulas section had the calculation-mode radios and Enable iterative
calculation but not the two limits that formulas.calculationOptions already
takes. They now sit under the checkbox, disabled while iteration is off, and
commit when the field loses focus. Like Excel, out-of-range entries
(Maximum Iterations outside 1-32767, a negative or non-numeric Maximum
Change) are refused with a message instead of being clamped by the command;
the Enter that committed the field no longer also dismisses that message.
Labels and messages are translated for every interface language. A kittest
covers the disabled state, valid and invalid entries, and undo refreshing
the field. Drops the stale "no Options dialog yet" from gaps.md and
function-parity.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/insert.rs:477
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As in Excel, clearing a filtered selection leaves the rows the filter
hides as they are; rows hidden by hand are still cleared. Checked boxes
in hidden rows no longer hold back Delete on the boxes showing.
When a dynamic array's anchor gets a new formula, the formula is evaluated
while the old array is still laid out, so it can read the old spilled
values. The anchor was then never evaluated again: it is left out of its
own spill area's dependents, so as not to read its own array.
- `A1` was `=SEQUENCE(3)` and becomes `=SUM(A2:A3)+1`: it showed 6 (2 + 3
+ 1) with A2:A3 empty; recalculating from scratch gives 1.
- `A1` was `=SEQUENCE(3)` and becomes `=SEQUENCE(1,A2+1)`: it read A2 as 2
and spilled over A1:C1 instead of showing 1.
The anchor is now evaluated again when its old array is gone or partly
freed. It is still left out when its new array covers the old one, where
it would read its own array.
Regression test in calc
(`an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty`:
the new formula stops spilling, spills elsewhere, or spills over part of
the old area); it fails without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When two dynamic arrays of one recalculation want the same empty cells,
both look free while the pass runs, so formulas evaluated in that pass
read both arrays. The one laid out second becomes #SPILL! at write-back,
but nothing evaluated its readers again, and they kept its values.
With B1 `=SEQUENCE(3)` and A2 `=SEQUENCE(1,3)` (both want B2), a full
recalculation (opening a file, Calculate Now) leaves A2 at #SPILL!, yet
`=A2*10` showed 10, `=SUM(A2#)` 6, and `=C2` and `=SUM(C2:C3)` 3, though C2
is empty. Entering the same formulas one at a time gave the right values.
An array blocked at write-back now sends the formulas that read its anchor
or the area it would have spilled into to the next round, where they read
#SPILL! and empty cells.
Regression test in calc
(`formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors`);
it fails without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the
coupon, bond and T-bill functions, DISC, BIN2DEC, IM* and the other Analysis
ToolPak functions give #VALUE! in Excel 365 for a reference to more than one
cell in a value argument, in dynamic-array and legacy formulas alike, where
GridCraft lifted the range and spilled. Array constants and computed arrays
still lift, and range arguments (holidays, cash flows, schedules) and the
.INTL weekend still take ranges.
FnSpec gains a value_args mask, filled at registration from one table in
lift.rs; call_lifted turns a multi-cell reference at a value position into a
scalar #VALUE! before lifting, so EOMONTH(A1:A3,{1;2}) is a 2x1 array of
#VALUE! as in Excel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A formula read with `cm` (Excel's XLDAPR dynamic-array record in
xl/metadata.xml) now carries `Formula::dynamic`, and the writer keeps the
`cm` and the metadata part for it even when the formula returns one value
and the export shape analysis would not need array evaluation. Before, such
formulas were saved as legacy formulas, so Excel recalculated some of them
with implicit intersection (SUMPRODUCT(IFERROR(range/range,0)) changed
value), and workbooks whose every dynamic formula was scalar lost
xl/metadata.xml altogether.
The mark survives moves, row and column insertion or deletion, sheet and
name renames and LAMBDA call-case fixes; retyping a formula drops it and
the writer decides from the formula's shape, as before. It is serialized
only when set, so saved documents without it are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel 365 reads a dollar sign before accounting parentheses ($(1,234),
$ (5)) and a percent sign after them ((5)%) as negative numbers when the
text is typed, coerced by arithmetic or passed to VALUE. GridCraft gave
#VALUE! for these.
The parentheses branch no longer recurses: Excel reads only an unsigned
plain number or fraction inside, so (-5), ((5)), ($-5), and dates or
times in parentheses are now #VALUE! as in Excel (they used to give 5
or a negative serial). $5% is #VALUE! too: Excel never combines the
currency and percent signs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compared numbers at
15 significant digits (through compare / compare_numbers), so a computed
key such as =0.1+0.2 (0.30000000000000004) found 0.3. Excel 365 compares
lookup keys exactly in exact, approximate and binary search modes, while
=, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF,
MAXIFS) keep the 15-digit comparison. Observed in Excel via COM with
synthetic values.
lookup_equal now compares numbers with ==, and the lookup searches order
values with lookup_order (exact for numbers, compare otherwise).
compare_numbers and the criteria path are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Alt, H, B letter ran home.borders with no params, which defaults to
a bottom border, so H,B,N (No Border) and H,B,A (All Borders) both drew a
bottom border. keytips.json entries now take optional fixed `params`
(Action::CommandWith / DialogWith), and each Borders menu letter passes the
preset Excel's menu gives it. H,B,M opens Format Cells on the Border tab.
Erase Border, Line Color and Line Style are reserved (pending) instead of
drawing a bottom border.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reference over 16,384 cells was cut to the sheet's used range when it was
read into an array. Next to a full-size operand the two no longer lined up:
SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2)) gave #VALUE!,
D3:AZ400*B3:B400 filled with #N/A, and ROWS, COUNTBLANK and --(r="") lost
the blanks past the data. Excel keeps the reference's size.
Bounded references now keep their full shape up to one column's worth of
cells (1,048,576). References that run to the edge of the sheet (A:A,
A2:A1048576, several whole rows) and larger blocks still stop at the used
range, so whole-column formulas stay fast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
selection.set refuses a cell the protection forbids selecting, selection.move steps on to the
next selectable cell, and review.protectSheet keeps the sheet's current selection rights unless
told otherwise. The dialog gains the two select options and insert/delete columns.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Home > Format, HOP and RPS always opened the Protect Sheet dialog, which re-protected
the sheet. open_dialog now turns protectSheet into unprotectSheet for a protected sheet,
and unprotects at once when there is no password.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
As in Excel, right-clicking a row or column heading offers Format
Cells…, between Clear Contents and Row Height… / Column Width….
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quick right-click delivers press and release in one frame. The menu
opens at the pointer, and its corner snaps to the nearest physical pixel
(252.46 -> 252.5 on a Retina display), which can leave the pointer just
outside it. The close check then saw that frame's press as a click
outside the menu and closed it right away, so the menu often flashed and
vanished on macOS.
The menu remembers the frame it opened in, and that frame's press no
longer counts as a click elsewhere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking column B's header on a sheet with A2:C2 merged selected A:C,
because selection.set grows every area until merged cells are whole.
Excel leaves header selections alone: column B stays B:B and row 3 stays
3:3. Cell ranges still take merged cells whole (B1:B3 -> A1:C3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A formula that reads one later in row-major order (further down, or on a
later sheet) is calculated on demand, inside the formula that reads it.
Each level costs stack, so real models with chains of about 1,000 such
formulas overflowed the 1 MB main-thread stack on Windows and crashed on
open. Past 2,000 levels the chain gave #CIRC! instead.
On-demand evaluation now goes at most 64 formulas deep. A formula needed
deeper suspends the evaluations in progress: it is calculated from the
top, then the suspended ones again, reading the results kept so far.
Chains of any length calculate, with the same results. A suspended
formula read again is a circular reference, as a formula in progress is,
so loops are found at the same cell as before.
Regression tests build the chains in code: 20,000 formulas each reading
the row below, the same hopping between two sheets, a 5,000-formula loop,
and a loop closing into the middle of a deep chain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
insert.slicer and table.insertSlicer looked for the table named by `table` only on the active
sheet. With another sheet active, the call failed with "Select a cell inside a table first, or
pass `table`" although `table` was passed. Other table commands find a table by name in the
whole workbook (Workbook::table).
The named table is now found on any sheet, and the slicer goes on that table's sheet. `at` is
read on that sheet, so "Sheet1!G2" names the table's own sheet. A name that matches no table
gives "no table named `X`".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Column Width, Row Height and the other form dialogs now open with the cursor
in their first field and its value selected, as in Excel: Alt+H, O, W, then
12 and Enter sets the width without clicking into the field first.
- The first text or number field takes focus once it can (a new window's
first frame is a sizing pass that ignores focus requests), and its value
is selected so typing replaces it.
- Number fields commit while typing, so Enter or OK in the same frame no
longer confirms the old value.
Tests: tests/dialog_focus.rs (Column Width and Row Height: type, Enter,
value applied).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
View > Zoom is now one Magnification column as in Excel: 200%, 100%, 75%,
50%, 25%, Fit selection and Custom with its percentage box. The current zoom
is pre-selected (Custom when it matches no preset), the box shows whole
percentages clamped to 10..400 and typing in it selects Custom. Fit
selection runs view.zoomToSelection with the grid's size, the same answer
as the ribbon's Zoom to Selection. The window is 220 px wide instead of
380; its height is left to the shared dialog sizing work (#978).
Magnification and Fit selection are translated in every catalog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr
As in Excel, a right-click on an inactive tab now activates that sheet, so
the tab menu's Protect/Unprotect Sheet and Select All Sheets act on the
sheet that was clicked rather than the one that was active.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr
The cell and header right-click menus, the ribbon dropdown menus and the data
validation pick list drew their items with Button::frame(false), which turns
the background off in every state, hover included. frame_when_inactive(false)
keeps them flat at rest and shows the hover background, as Excel's menus do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A category range two or more columns wide beside a column of values (year beside month, Excel's
multiLvlStrRef) was read as one flat list: 36 rows in two columns became 72 categories, the
series sat in the left half of the plot and the years and months shared one row of labels.
Saving wrote the range as a plain strRef, so Excel lost the two-level axis too.
- gridcraft_core::category_levels: the shape of a category range beside its values (levels are
columns when the points run down the rows, rows when they run along the columns; up to three
outer levels), shared by the chart and xlsx crates.
- ChartData.category_levels: the outer levels, nearest the labels first, one entry per category,
non-empty where a group starts. resolve fills it; a single column is unchanged.
- render: the outer levels in rows under the labels, a group's name centred under its categories
with a separator at each boundary; in bar charts in columns beside the labels.
- xlsx: <c:cat> written as multiLvlStrRef with the level caches (innermost first), so the axis
survives a round trip.
Tests in core (shapes), chart (resolve and layout for columns, bars and a row of values) and
xlsx (written XML, read-back, stability, a single column stays strRef). chart-parity, gaps § 4
and the ROADMAP progress log updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The palette every colour menu shares ends with the custom colours last used
(Recent Colors, newest first, ten at most, each once) and a More Colors… button
that opens a picker in the menu: the saturation/value area with its hue bar, egui's
red-green-blue boxes, a hex box (#RRGGBB, RRGGBB, #RGB) with the new colour beside
it, OK and Cancel. OK applies the colour like a swatch and remembers it.
The ribbon's Fill Color and Font Color menus close only on a click outside them or
after a pick, so the picker stays open while it is used; the Format Cells pickers
show it inline. The tab-colour and chart-series menus are sub-menus that close on
any click inside, so they show Recent Colors but not the picker.
`formulas.evaluate` (behind `gridcraft-cli eval` and MCP's `evaluate_formula`) turned any
formula that didn't parse into #NAME?, so `=SUM(1;2)` looked like an unknown function. It now
parses the way `cell.set` does (missing closing parentheses are added) and returns the same
errors: "There's a problem with this formula: expected `,` or `)`, found Semicolon", or the
wrong-argument-count message. An unknown function still evaluates to #NAME?, as in Excel.
Typing then Right or Down committed through the Enter/Tab path, so Right
continued the run and Down went back to its starting column. Found by
@richard1912 against Excel 365: from B2, type, Tab, type, Right, Enter
lands on D3, and with Down on C4.
MAP, BYROW, BYCOL, SCAN and MAKEARRAY put one value in each cell, but a
LAMBDA returning several values silently gave its first one:
MAP({1,2},LAMBDA(x,{1,2})) was {1,1} and BYROW(…,LAMBDA(r,r)) the first
column. As in Excel, the whole result is now #CALC!, as arrays can't
nest (a single-value array is still fine). MAP with a LAMBDA taking a
different number of parameters than it has arrays, and BYROW or BYCOL
with one taking other than one, ran anyway; Excel gives #VALUE!.
home.insertRows {"rows":"3"} and home.insertColumns {"cols":"B"}
failed with "not a range": only spans such as 3:3 or B:B parsed. The
same went for deleting, hiding, sizing and grouping rows and columns.
A single row number or column letter now means that one line.
After each round, every array formula that was evaluated queued the
dependents of its spill area again, even when it spilled the same area
with the same values. Two arrays that read each other's spill areas
then queued each other in every round, up to the limit of 8: one edit
evaluated 24 formulas instead of 3. An unchanged spill now leaves its
dependents alone. last_recalc_cells counts the formulas of all rounds.
Flash Fill read stored values, so a date was the serial number 45306,
not 2024-01-15: with "15/01/2024" given for 2024-01-15 it found no
part of the source in the example and filled every row with
"15/01/2024" again. It now reads the cells' displayed text, as Excel
does.
home.formatCells documents {style: {numFmt?, ...}}, but the style patch
was merged with the style's snake_case field names, so numFmt (and
borders.diagUp/diagDown) were silently ignored. camelCase keys in the
patch are now renamed to the style's own; snake_case keys still work.
The size box is one frame with the Font name box's fill, border, height and
corners, with the text field and the dropdown arrow inside it (focus shows
the selection stroke). The size list drops directly below the box and is as
wide as it, instead of opening 220 pt wide under the arrow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc
`{"sheet": null, "cols": "Data!B:B"}` was refused as naming a sheet other than `sheet`,
while `target_sheet` and the cell parameters read a null `sheet` as absent. The prefix now
decides, as it does when `sheet` is left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`style: {fill: "none"}` (or "auto") went through the colour pass and came out as a solid
fill in the automatic colour. `home.fillColor` reads the same word as no fill, so the rule
style does too now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
data.validation took any `type`, `operator` or `errorStyle` and fell back
to a default: a typo such as `type: "lst"` saved an "any value" rule
(`type="none"`), which validates nothing, and an unknown operator became
`between`. Each is now an error that lists the accepted values; `any`
stays a type, and the operator aliases (`greaterThan`…) still work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
data.validation {type: "list", formula1: "Open,In progress,Closed"}, which is
how Excel's Source box takes a list, worked inside GridCraft but was saved as
<formula1>Open,In progress,Closed</formula1>. In a file an inline list is a
string literal, "Open,In progress,Closed" (what Excel writes and what our own
reader expects); unquoted, formula1 reads as a formula of names.
- validation quotes such a source when the rule is created, doubling inner
quotes, using the test list_items already used to tell a literal list from
a reference or a name (now is_literal_list). A source typed with = (a
formula), a reference, a name, a sheet-qualified range and an already quoted
list are stored as before.
- list_items reads a quoted list as a string literal: "" inside stands for
one quote, so an item with a quote reads back the same after the change and
from a file.
Creating the rule is the one place that still knows whether the user typed =,
so the XLSX writer is left alone: it can't tell OFFSET(...) entered as a
formula from a literal list, and a rule read from a file is already quoted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
home.conditionalFormat deserialized `style` straight into the model's
Style, so a programmatic caller had to write colours as {"Rgb": 16773836}:
"#FFF2CC", which format_range, colorScale and dataBar take, was refused
with a serde message naming internal types. A key the struct doesn't have
(`style: {preset: …}`, `fillColor`) was dropped, which saved a rule with an
empty format that matches and shows nothing. And a cellIs `value` already
written as a string literal ("\"Closed\"", as in the file and openpyxl)
was quoted again into the text "Closed" with its quotes, so it never
matched.
`style` colours (font, fill fg/bg, borders) may now be "#RRGGBB",
{theme, tint} or "auto", and `fill` may be a colour alone (a solid fill);
a fill colour with no pattern is solid. Unknown keys and a non-object
`style` are refused. A cellIs value that is one string literal is kept.
The model's own shape keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
home.rowHeight and home.columnWidth take pixels at 96 dpi, the model's unit
(header drags and the Row Height dialog send pixels), but their parameter docs
said points. An agent asking for a 30 pt row got `ht="22.5"` in the file, and
the range check accepted 500 while its message said "between 0 and 409 points".
- The params docs say pixels: `height: 20 (pixels) | points: 15`,
`width: 64 (pixels) | chars: 8.43`, and `{width: 64 (pixels)}` for
home.defaultWidth. Pixel values behave as before.
- home.rowHeight takes `points`, Excel's unit (Row Height dialog, `ht` in the
file), as home.columnWidth already takes `chars`.
- Range errors give the limit in the unit passed: 546 pixels (409.5 points),
409.5 points, 1800 pixels, 255 characters. `chars` is checked against 255,
Excel's limit and the widest column a file holds (it went to 256.4 through
the pixel limit).
- The model's size comments say pixels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
view.freezePanes, home.rowHeight, home.columnWidth, the AutoFit, Hide and
Unhide row/column commands and home.defaultWidth always changed the active
sheet: a `sheet` parameter was silently ignored, so a programmatic caller
laying out a sheet that isn't active changed the wrong one, and `Sheet!`
prefixes on `rows`/`cols` were refused as "not a range".
They now take `sheet` (name or index) like the commands that already use
target_sheet, accept a `Sheet!` prefix on `rows`/`cols` (and on Freeze
Panes' `cell`, which was refused), and refuse a prefix that contradicts
`sheet`. The selection belongs to the active sheet, so another sheet needs
explicit lines or a cell. Freeze Top Row, Freeze First Column and Unfreeze
Panes take `sheet` too. Without `sheet`, nothing changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
env! fixes the path to contributors/ when the build script is compiled.
After a checkout is moved or copied, the compiled script keeps looking in
the old location: the About window loses its contributor list, and
because the rerun-if-changed path no longer exists, Cargo reruns the
script and rebuilds the UI crate on every build. Reading the variable at
run time follows the checkout.
CI failed with cargo clippy --workspace --all-targets -- -D warnings,
not a test. The actual cause: I'd been validating locally against
rustc/clippy 1.95.0 (the toolchain this machine had installed before
now), while CI installs plain "stable" (1.99.0 as of this run) — two
lints new since 1.95.0 fired there and nowhere in my local checks:
- clippy::int_plus_one on `src.end.row + 1 <= cap` in grid.rs,
simplified to `src.end.row < cap` (same meaning, no overflow risk
change: row is a u32 and cap is already saturating-clamped below
MAX_ROWS - 1, so src.end.row + 1 can't wrap).
- clippy::disallowed_methods on the new fill_handle_perf example's
three Instant::now() calls — the project's own lint from the time-
panic fix (#125 history): reading the clock traps on wasm. This
example is native-only (a `cargo run --release` benchmark, never
built for wasm), so it gets the same #![allow(...)] perf.rs already
carries, with the same justification.
Updated the local toolchain to stable (rustup update stable) and
re-ran cargo clippy --workspace --all-targets -- -D warnings clean
before this push, to actually match what CI checks instead of
guessing again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cargo fmt reformatted the new example's while-loop condition onto its
own line; clippy's nonminimal_bool caught a negated is_some_and that
reads more plainly as is_none_or.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to #210, which the reviewer correctly rejected: the stated
cause (a sparse column with a stray distant value) doesn't hold, since
the scan already stops at the first empty cell. This is a different,
narrower fix that doesn't change behaviour for any input — it never
truncates a long fill, unlike #210's cap — only how fast a genuinely
long, densely-populated run is scanned.
grid.rs's fill-handle double-click called Sheet::value_ref once per
row to find where the neighbouring column's data ends. Each call does
a band lookup + row lookup + column lookup (three BTreeMap probes) via
CellStore::get. For a real dense run (e.g. a filled column near the
1,048,576-row sheet limit) that's a lot of point lookups on the UI
thread in one frame: ~59ms for a 1,000,000-row run in a release build
(measured with the new fill_handle_perf example), likely worse under
wasm where GridCraft actually runs in the browser.
- crates/model/src/store.rs: CellStore::contiguous_value_run walks
each band's row BTreeMap with `range` (one in-order pass per band)
instead of one `get` per row, so a dense run costs roughly one
tree-traversal step per row instead of one binary search per row.
Deliberately narrower than `has`/`get`: a stored cell always decides
the row on its own (even with an empty value), matching what
`value_ref`'s callers actually test for, not `has`'s "formula counts
regardless of its cached value" (used for Ctrl+arrow navigation).
- crates/model/src/sheet.rs: Sheet::last_contiguous_nonempty layers
`spill` back in wherever contiguous_value_run finds no stored cell,
exactly where plain `value_ref` would fall through to it.
- crates/ui-egui/src/grid.rs: the fill-handle double-click now calls
this once instead of looping over value_ref.
Result: 999,999-row dense run, 59.2ms -> 24.7ms (2.40x), same answer
(asserted in the example). Four new tests in sheet.rs, including a
300-trial property test against a naive value_ref-loop reference
(spills, formula-with-empty-value, gaps, blanks) — all pass, plus the
full existing suite (`cargo test -p gridcraft-model -p gridcraft-ui-egui
-p gridcraft-engine`; the only failures are #210-era gridcraft-engine
CSV tests already failing on main, unrelated to this change).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 02:01:49 +02:00
639 changed files with 114748 additions and 43296 deletions
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
# A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
case "${tag%%+*}" in
*-*)
echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
exit 1
;;
esac
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
@@ -23,10 +23,10 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
@@ -37,12 +37,13 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
## Running and looking at the app
-`cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
@@ -69,3 +70,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
-Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
-`cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
@@ -51,3 +51,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
The following text is reproduced verbatim from Hack-Regular.txt:
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png" width="128" height="128" alt="GridCraft icon: a spreadsheet page on a green tile">
</p>
<h1 align="center">GridCraft</h1>
<p align="center">
<b>Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust.</b>
<b>Spreadsheets and data; an open-source, high-performance modern spreadsheet application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source, clean-room take on the Excel workflow. It runs natively on macOS,
A fast, open-source, clean-room take on modern spreadsheet workflows. It runs natively on macOS,
Windows, Linux and BSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
</p>
@@ -24,6 +28,9 @@
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-1f9d55?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-147a40?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-1f9d55?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_gridcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/gridcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -67,8 +74,8 @@
<td width="50%" valign="top"><img src="docs/images/grades-formulas.png" alt="A gradebook table with averages and letter grades computed with IFS, green highlights on A grades and data bars, with the Formulas ribbon tab open showing the function library" width="100%"><p align="center"><sub><b>Formulas.</b> 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.</sub></p></td>
</tr>
<tr>
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
<td width="50%" valign="top"><img src="docs/images/pivot.png" alt="A PivotTable summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the PivotTable Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas" width="100%"><p align="center"><sub><b>PivotTables.</b> Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.</sub></p></td>
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
<td width="50%" valign="top"><img src="docs/images/pivot.png" alt="A pivot table summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the PivotTable Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas" width="100%"><p align="center"><sub><b>Pivot tables.</b> Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.</sub></p></td>
</tr>
<tr>
<td width="50%" valign="top"><img src="docs/images/format-chart.png" alt="The sales dashboard with the column chart selected: resize handles on the chart, its source data outlined in the table, the Chart Design tab open and the Format Chart Area pane on the right" width="100%"><p align="center"><sub><b>Charts.</b> Seventeen chart types, Chart Design tab and a Format pane; selecting a chart outlines its source data.</sub></p></td>
@@ -78,19 +85,19 @@
## Why GridCraft
- **Familiar.** Excel's ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill
handle, point-mode formula entry with coloured references, Format Cells, Paste Special,
conditional formatting, tables, charts and more. If you know Excel, you already know GridCraft.
- **Familiar.** Ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill handle,
point-mode formula entry with coloured references, Format Cells, Paste Special,
conditional formatting, tables, charts and more. Everything is right where you expect it.
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
@@ -197,9 +204,9 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
## Roadmap
GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file
compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions
([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we
compatibility are still rough. It covers 89% of standard ribbon commands in our catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 standard worksheet functions
([`docs/function-parity.md`](docs/function-parity.md)); this is a parity inventory, not a count of working implementations. Weighted by what spreadsheet users rely on, we
estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large
workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage,
numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [`docs/gaps.md`](docs/gaps.md).
@@ -236,7 +243,7 @@ bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The
web backend defaults are unchanged.
**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no
graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every
graphics card with a driver for it, it only offers the Windows-provided software renderer, which draws every
frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if
the first renderer fails to start, it tries the other one once. The log file records the choice
(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is
@@ -334,8 +341,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and GridCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft, Excel and Microsoft 365 are trademarks or registered trademarks of Microsoft Corporation in the United States and/or other countries. GridCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation; these names are used only to describe the workflows it is compatible with.</sub>
**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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **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.
@@ -21,7 +21,8 @@ This page is the summary. The detail is in:
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
## Languages
@@ -93,7 +94,9 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
added 10-11), the
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
(#38). Japanese (10-17) and Brazilian Portuguese also translate the dialog titles and labels,
| Japanese | ja | menus, File view, dialogs, messages | ~43% |
| German | de | menus only | ~12% |
| Korean | ko | menus only | ~12% |
| Vietnamese | vi | none | 0% |
Other shipped languages: 2 (Russian and German, menus only, ~12%).
Other shipped languages: 3 (Russian and German, menus only, ~12%; Dutch, menus, File view and dialog titles, ~24%).
## Upcoming
@@ -132,6 +135,53 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 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 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 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`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
| 2026-10-24 | PivotTables: Enable show details (`pivot.options``enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart``{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in |
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) |
| 2026-10-19 | Linux: "Use system setting" for the theme keeps following the desktop when the XDG Desktop Portal stops answering: each read gives up after 2 s and the next poll reconnects, where one unanswered call stalled the watcher for the rest of the session |
| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB |
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip |
| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148)
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 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) |
@@ -148,6 +198,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
@@ -193,6 +244,35 @@ 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: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
| 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! |
| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it |
| 2026-10-22 | minor | Progress log: a native window per workbook window |
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
@@ -200,6 +280,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | UI building blocks; progress log row |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state.
/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.