Compare commits

..
225 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 9f896c1249 Bump version to 0.5.0 for release (#227)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:10:53 -04:00
Brandon ThomasandClaude Opus 5.5 90b3c41eec Fix-up: #83's array-export test expects #156's legacy-array read (cached values refilled on recalc)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:59:42 -04:00
Brandon ThomasandClaude Opus 5.5 c6392e6380 Fix-up: chartex frames use the (open, close) anchor tags after merging #161 onto the move-only anchor change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:52:46 -04:00
Brandon Thomas 502c588690 Merge pull request #164 from LloydNicholson/macos-open-documents
macOS: open documents from Finder, the Dock and `open -a` (#69)

# Conflicts:
#	apps/gridcraft/src/main.rs
2026-10-10 15:50:57 -04:00
Brandon Thomas 88d0c772fb Merge pull request #161 from nihilist-narwhal/fix-chart-kinds-roundtrip
Charts: histogram, box & whisker, waterfall, funnel, treemap and sunburst keep their kind through XLSX
2026-10-10 15:50:48 -04:00
Brandon Thomas f23281db8c Merge pull request #144 from harrisonoconnorhover/codex/cross-sheet-cut
Preserve formula references when cutting cells across sheets

# Conflicts:
#	ROADMAP.md
#	crates/formula/src/adjust.rs
#	crates/formula/src/tests.rs
2026-10-10 15:48:08 -04:00
Brandon Thomas 49fbae245a Merge pull request #156 from nihilist-narwhal/fix-legacy-array-formulas
Legacy array formulas: fill the range they were entered in

# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	crates/ui-egui/src/formula_bar.rs
2026-10-10 15:47:56 -04:00
Brandon Thomas de0c715662 Merge pull request #186 from MarekSpodBiedry/issue-126
Write comment and hyperlink maps with A1 keys so JSON save works (#126)

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-10 15:47:35 -04:00
Brandon Thomas b07249de75 Merge pull request #160 from itrycodinganditsbad/fix-month-fill-clamp
fill: clamp month and year date series to the month's last day

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-10 15:47:32 -04:00
Brandon ThomasandClaude Opus 5.5 c15a51c959 Fix-up: move the #179 table-style test module to the end of tables.rs after merging #149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:47:19 -04:00
Brandon Thomas dd185ff12f Merge pull request #157 from nihilist-narwhal/fix-blocked-spill-dependents
Recalc: formulas over a blocked spill see #SPILL!; no spilling in tables
2026-10-10 15:47:12 -04:00
Brandon Thomas c3cfc92186 Merge pull request #155 from nihilist-narwhal/fix-range-blank-cells
Ranges keep their size past the used range; spilled blanks show 0
2026-10-10 15:47:12 -04:00
Brandon Thomas 9fea6235f7 Merge pull request #148 from nihilist-narwhal/fix-stale-recalc-values
Recalc: stale spills and values after edits (fixes #133, #139)
2026-10-10 15:47:12 -04:00
Brandon Thomas 84a414a705 Merge pull request #158 from nihilist-narwhal/fix-chart-kinds-combo-stock
Charts: combo and stock charts keep their kind in XLSX
2026-10-10 15:47:11 -04:00
Brandon Thomas 1e5edbd43c Merge pull request #154 from nihilist-narwhal/fix-repeated-headers
Repeated headers: number pivot fields and table columns in linear time
2026-10-10 15:47:11 -04:00
Brandon Thomas 5c456df4b7 Merge pull request #152 from nihilist-narwhal/fix-whole-sheet-selection
Whole-sheet selection: Fill and Remove Duplicates no longer run out of memory or time
2026-10-10 15:47:10 -04:00
Brandon Thomas 92fb7cd1b3 Merge pull request #149 from nihilist-narwhal/fix-table-header-edits
Tables: typing in the header row renames the column; Go To a table selects its data

# Conflicts:
#	crates/engine/src/tables.rs
2026-10-10 15:47:10 -04:00
Brandon Thomas 5545e72944 Merge pull request #188 from MarekSpodBiedry/issue-145
Release focus when the sheet tab rename ends (#145)
2026-10-10 15:46:34 -04:00
Brandon Thomas 960c79a328 Merge pull request #187 from MarekSpodBiedry/issue-57
Compare numbers at 15 significant digits like Excel (#57)
2026-10-10 15:46:33 -04:00
Brandon Thomas 645782f5e3 Merge pull request #185 from MarekSpodBiedry/issue-135
Report an error when file commands get no file in MCP and CLI (#135)
2026-10-10 15:46:32 -04:00
Brandon Thomas 87d64f20e1 Merge pull request #184 from MarekSpodBiedry/issue-136
Honor search in the desktop engine.commands handler so connected list_commands filters (#136)
2026-10-10 15:46:32 -04:00
Brandon Thomas d031c8f37e Merge pull request #183 from MarekSpodBiedry/issue-140
Shrink 3-D sheet spans when an end sheet is deleted (#140)
2026-10-10 15:46:31 -04:00
Brandon Thomas cda29c5b73 Merge pull request #182 from MarekSpodBiedry/issue-141
Deliver Export as PDF as a browser download (#141)
2026-10-10 15:46:30 -04:00
Brandon Thomas c21123e375 Merge pull request #181 from MarekSpodBiedry/issue-127
Keep comma when a semicolon or tab is missing from some CSV lines (#127)
2026-10-10 15:46:29 -04:00
Brandon Thomas 75ede09917 Merge pull request #180 from MarekSpodBiedry/issue-129
Keep the untouched part of a data validation range when clearing or replacing part of it (#129)
2026-10-10 15:35:45 -04:00
Brandon Thomas 14402fc5a3 Merge pull request #179 from MarekSpodBiedry/issue-130
Clamp table style number to 1 so zero-numbered styles don't underflow (#130)
2026-10-10 15:35:44 -04:00
Brandon Thomas e9fb005eed Merge pull request #178 from MarekSpodBiedry/issue-134
Rename sheet-qualified defined names with their sheet (#134)
2026-10-10 15:35:44 -04:00
Brandon Thomas 5578c09e55 Merge pull request #159 from itrycodinganditsbad/fix-min-no-args
functions: MIN() accepts zero arguments
2026-10-10 15:35:43 -04:00
Brandon Thomas 00df1908a7 Merge pull request #153 from nihilist-narwhal/fix-jump-without-direction
selection.move: Ctrl+arrow without a direction no longer loops forever
2026-10-10 15:35:42 -04:00
Brandon Thomas c4e4aeb293 Merge pull request #151 from nihilist-narwhal/fix-current-region
Current region: grow past 10,000 rows, in linear time
2026-10-10 15:35:42 -04:00
Brandon Thomas 20aa531d8c Merge pull request #150 from nihilist-narwhal/fix-paste-special-operations
Paste Special operations: keep text, errors and formulas
2026-10-10 15:35:41 -04:00
Brandon ThomasandClaude Opus 5.5 11ef0e6b04 Maintainer fix-up: compare clearly different numbers directly, skipping the 15-digit text round trip on the sort/lookup hot path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:35:13 -04:00
Brandon ThomasandClaude Opus 5.5 dfd3675324 Maintainer fix-up: drop wall-clock assertions from the repeated-headers test (flaky under load; Instant::now is a disallowed method)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:35:10 -04:00
Brandon ThomasandClaude Opus 5.5 f75ded37e7 Merge pull request #105 from harrisonoconnorhover/codex/cell-pictures
Add static pictures inside cells with XLSX roundtrip

Rebased onto main (conflicts with #83/#98 and main resolved by maintainer); the contributor's branch does not allow maintainer edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:02:44 -04:00
Brandon ThomasandClaude Opus 5.5 59c8e36d36 Merge pull request #83 from harrisonoconnorhover/codex/nonspilling-array-export
Preserve array evaluation for non-spilling XLSX formulas

Rebased onto main with a maintainer fix-up (LET bindings via split_last/as_chunks for clippy, no slice indexing); the contributor's branch does not allow maintainer edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:02:43 -04:00
Brandon ThomasandClaude Opus 5.5 5b15827ede Maintainer fix-up: leave English formula display untouched, match engine formula detection
English (and languages without a formula table) now shows stored formula
text verbatim instead of re-parsing it every frame; localized entry no longer
trims leading spaces before formula detection (" =1" stays text, as in the
engine). Adds a display round-trip test for string literals containing
separators, decimal commas and the intersection operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:49:52 -04:00
Brandon Thomas 3e40014eb0 Merge pull request #98 from harrisonoconnorhover/codex/spanish-formulas
Support common Spanish formula names and semicolon arguments
2026-10-10 14:49:52 -04:00
Brandon Thomas 82f42713a8 Merge pull request #108 from harrisonoconnorhover/codex/desktop-shortcut
Offer an optional desktop shortcut in the Windows installer

# Conflicts:
#	ROADMAP.md
2026-10-10 14:32:51 -04:00
Brandon ThomasandClaude Opus 5.5 60aa2dfe07 Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#189)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:06 -04:00
Harrison O'Connor-Hoover ada33d1ad5 Fix #45: follow the selected language for formula editing 2026-10-10 13:48:20 -04:00
Brandon Thomas e72e16a0ec Merge pull request #82 from edurbs/feat/i18n-portuguese
i18n: Brazilian Portuguese interface language (#79)
2026-10-10 13:25:35 -04:00
Brandon Thomas 580b14eeb9 Merge pull request #104 from neko233-com/feature/i18n-zh-ko-ru
i18n: add Simplified Chinese, Korean and Russian UI
2026-10-10 13:25:34 -04:00
Brandon Thomas 837bb00f2b Merge pull request #31 from justinjohn0306/feat/i18n-language
i18n: switch the interface language, defaulting to the system language (#20)
2026-10-10 13:25:34 -04:00
Brandon Thomas 4f10e48128 Merge pull request #38 from justinjohn0306/fix/thai-hebrew-font-fallback
Fonts: render Thai and Hebrew (and other non-Latin scripts) instead of tofu (#32)
2026-10-10 13:25:33 -04:00
Brandon Thomas f2ed982fdd Merge pull request #12 from justinjohn0306/fix/anchor-mode-sort
Objects: anchor modes — images/charts/shapes follow cells on sort (#5)

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-10 13:17:30 -04:00
Brandon Thomas eb4174723d Merge pull request #17 from justinjohn0306/fix/encrypted-file-message
File open: report password-protected (encrypted) files clearly (#11)

# Conflicts:
#	crates/engine/src/io.rs
2026-10-10 13:17:21 -04:00
Brandon Thomas bd1be73f2d Merge pull request #62 from justinjohn0306/fix/smooth-line-charts
Line charts: smooth (spline) curves (issue #52)

# Conflicts:
#	crates/xlsx/src/tests/mod.rs
2026-10-10 13:17:07 -04:00
Brandon Thomas 13f8f19248 Merge pull request #116 from nihilist-narwhal/fix-chart-switch-and-scatter
Charts: Switch Row/Column on charts read from a file; scatter with text X values
2026-10-10 13:16:59 -04:00
Brandon Thomas 76f9e2625f Merge updated #104 (latest main) into feat/i18n-portuguese 2026-10-10 13:15:11 -04:00
Brandon Thomas d51d4e51b9 Merge updated #31 (latest main) into feature/i18n-zh-ko-ru 2026-10-10 13:15:11 -04:00
Brandon Thomas 6e935a3c8c Merge origin/main into feat/i18n-language
# Conflicts:
#	crates/engine/src/cmd/file.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-10 13:15:10 -04:00
MarekSpodBiedry 2cc7dde6c3 Release focus when the sheet tab rename ends (#145) 2026-10-10 19:10:19 +02:00
MarekSpodBiedry 1d33ea81be Write comment and hyperlink maps with A1 keys so JSON save works (#126) 2026-10-10 19:08:53 +02:00
MarekSpodBiedry 0f21244e9e Compare numbers at 15 significant digits like Excel (#57) 2026-10-10 19:08:16 +02:00
MarekSpodBiedry dcda5a52ce Deliver Export as PDF as a browser download (#141) 2026-10-10 19:06:36 +02:00
MarekSpodBiedry 47210cde01 Shrink 3-D sheet spans when an end sheet is deleted (#140) 2026-10-10 19:06:20 +02:00
MarekSpodBiedry fe162f8f23 Report an error when file commands get no file in MCP and CLI (#135) 2026-10-10 19:05:56 +02:00
MarekSpodBiedry c69446aff0 Keep comma when a semicolon or tab is missing from some CSV lines (#127) 2026-10-10 19:05:45 +02:00
MarekSpodBiedry 2faf8ffc78 Honor search in the desktop engine.commands handler so connected list_commands filters (#136) 2026-10-10 19:05:33 +02:00
MarekSpodBiedry 9400dbea2b Keep the untouched part of a data validation range when clearing or replacing part of it (#129) 2026-10-10 19:05:23 +02:00
MarekSpodBiedry b19ffabdaf Rename sheet-qualified defined names with their sheet (#134) 2026-10-10 19:04:13 +02:00
MarekSpodBiedry 82d3c5422f Clamp table style number to 1 so zero-numbered styles don't underflow (#130) 2026-10-10 19:03:55 +02:00
Brandon Thomas 19094e2d62 Merge pull request #36 from harrisonoconnorhover/fix/rich-table-clipboard
Fix copying selected cells as rich HTML tables
2026-10-10 12:58:38 -04:00
Brandon Thomas e20a7b41af Merge pull request #84 from harrisonoconnorhover/codex/text-box-editing
Allow editing and selecting text inside worksheet text boxes
2026-10-10 12:58:37 -04:00
Brandon Thomas cb794080b9 Merge pull request #43 from harrisonoconnorhover/codex/ribbon-keytips
Add initial Alt ribbon keytips and legacy shortcuts (#33)
2026-10-10 12:58:36 -04:00
Brandon ThomasandClaude Opus 5.5 7f2146476e Fix clippy on main: allow the clock read in native-only import tests
#101/#100 added tests that name temp dirs with SystemTime::now(); #125's
disallowed-methods lint (wasm has no clock) landed alongside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:58:22 -04:00
Brandon ThomasandClaude Opus 5.5 ca4011dc5e Review fix-up: merge fixed #104; Portuguese as an i18n/pt.rs module
Merge the fixed #104 branch (which carries #31 and #38) and restructure
the Brazilian Portuguese language onto its per-language layout:

- The PORTUGUESE table moves, unchanged, to crates/ui-egui/src/i18n/pt.rs
  as pt::translate, like zh/ko/ru. Language::PtBr gets code() "pt"
  (parse also accepts pt-BR/pt_BR via the tag reduction); it is still
  saved as "pt".
- No per-language command: the View combo and the control channel switch
  with app.language.set {"language": "pt"} (app.language.portuguese is
  dropped); error text, engine.commands params and the snapshot example
  list every code.
- pt.rs covers the keys added since (Page Layout|Orientation, the display
  theme combo, and the Sheet Options View/Print checkboxes, Exibir /
  Imprimir); the coverage test checks pt like zh/ko/ru.
- people.toml keeps both new contributor entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:54:33 -04:00
Brandon Thomas b68ae0fe53 Merge pull request #100 from harrisonoconnorhover/codex/ods-import
Import worksheet data and cached values from ODS files
2026-10-10 12:52:43 -04:00
Brandon Thomas 09b1ac8d16 Merge pull request #101 from harrisonoconnorhover/codex/xlsb-import
Import worksheet data and cached results from XLSB workbooks
2026-10-10 12:52:42 -04:00
Brandon ThomasandClaude Opus 5.5 f54c0ca2a6 Review fix-up: merge fixed #31; language-ordered Han faces on #38's fallback
Merge the fixed #31 branch (which carries #38's font fallback) and resolve:

- theme.rs: build on #38's fallback_faces/load_fallback instead of the old
  load_cjk. The interface language only reorders the two Han entries
  (sys-cjk-han-ja / sys-cjk-han-zh; Japanese first for ja, Chinese first
  otherwise); Hangul, Thai and Hebrew keep their place. The region-merged
  Noto Sans CJK .ttc is loaded once, with the leading language's face index.
- Fonts are rebuilt on a language switch only when the Han order changes
  (HanOrder), not on every switch. font_definitions()/setup_context() use
  the default order and never read the host locale.
- Tests stay locale-independent (UiState defaults to English; the desktop
  app applies the system language). The web build follows the browser
  language except CJK, which would render as tofu without system fonts.
- app.language.set error/params list all five codes; engine.commands lists
  it. zh/ko/ru gain the keys #31 added (Page Layout|Orientation, display
  theme). The Page Layout screen checkbox keeps English "View" via a
  "Sheet Options|View" key (was shown as "Screen").
- Coverage test checks zh/ja/ko/Thai/Hebrew/Cyrillic in both the UI and
  cell families by comparing glyphs with the tofu glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:52:00 -04:00
Brandon ThomasandClaude Opus 5.5 e8ec40da8b Review fix-up: build test anchors with ..Anchor::default()
Keeps the smooth round-trip test compiling as Anchor gains fields (e.g. #12's anchor mode).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:45:00 -04:00
Brandon ThomasandClaude Opus 5.5 d7c77e94cd Review fix-up: locale-independent defaults, lenient saved language, listed commands
- UiState::default() is English: tests, the kittest harness and the
  snapshot example no longer depend on the host's locale. The desktop app
  alone starts from the system language, when ui.json names no supported
  language (first run, an older file, an unknown value).
- An unknown or malformed saved language reads as English instead of
  failing the whole UiState and resetting every other preference.
- app.language.set takes {"language": "ja"} (with `code` as an alias, the
  name the PR description used); the View combo now uses it via
  Language::code(). The app.language.* commands are listed by
  engine.commands with the other UI commands and documented in
  docs/control-protocol.md.
- Home's text "Orientation" (方向) and Page Layout's print "Orientation"
  (印刷の向き) get separate keys through a `context|label` key form.
- Japanese: Hide Rows → 行を表示しない, Trace Dependents → 参照先のトレース;
  translate the display-theme combo merged in from main.
- system_theme test: the View tab now has two combo boxes; pick the theme one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:44:57 -04:00
Harrison O'Connor-Hoover 9dc3aaf9fa Support static pictures inside cells with XLSX roundtrip (#61) 2026-10-10 12:43:27 -04:00
Brandon ThomasandClaude Opus 5.5 fa9c6798ab Review fix-up: sort moves only objects inside the block; picture default oneCell
Re-anchor loop zips objects with their measured corners instead of indexing; a sort moves
only objects anchored inside the sorted block on the other axis too (a picture at Z3 stays
put when A1:B4 is sorted). insert.picture defaults to Move but don't size (Excel's
editAs="oneCell", which the writer emits for it), and object.setAnchorMode errors on an
unknown or empty kind. Tests for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:41:17 -04:00
Brandon Thomas b5fe936d83 Merge pull request #66 from LloydNicholson/fix/recalc-range-dependents
Issue #122: Recalc: linear in the number of range formulas
2026-10-10 12:40:00 -04:00
Brandon Thomas c450b545a6 Merge pull request #125 from LloydNicholson/fix/wasm-commit-trap
Issue #118: committing a cell in the web app no longer traps on the clock
2026-10-10 12:39:58 -04:00
Brandon ThomasandClaude Opus 5.5 d2cc499c1d Review fix-up: return copy/cut HTML only when requested
edit.copy and edit.cut build and return the HTML table only with
{"html": true}; the UI asks for it when the host can publish HTML, so
MCP and control-channel responses are not bloated by up to 4 MB. Tests
cover the default, html:false and html:true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:37:06 -04:00
Harrison O'Connor-Hoover 811cb116fd Release each native MSI preview through its handle (#24) 2026-10-10 12:36:07 -04:00
Brandon Thomas 36723ec310 Merge remote-tracking branch 'origin/main' into pr-12 2026-10-10 12:35:42 -04:00
Brandon ThomasandClaude Opus 5.5 f041904bf8 Review fix-up: only CFB files with an EncryptedPackage are encrypted
Every legacy .xls/.doc is a CFB/OLE2 file, so the signature alone isn't encryption. Walk the
compound file's directory (bounds-checked, bounded loops; raw UTF-16 scan when the structure
is unreadable) and report Encrypted only for an EncryptedPackage/EncryptionInfo stream;
other compound files are LegacyBinary with a "save as .xlsx" message. Tests build CFB bytes
in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:35:36 -04:00
Brandon Thomas 277f01265b Merge fixed #38 (font fallback) into feat/i18n-language
# Conflicts:
#	crates/ui-egui/src/grid.rs
#	crates/ui-egui/src/ribbon.rs
#	crates/ui-egui/src/widgets.rs
2026-10-10 12:31:57 -04:00
Brandon Thomas c5bf34d5d7 Merge pull request #81 from dylanmtaylor/ci/flatpak-pages
Publish Flatpak repository through GitHub Pages
2026-10-10 12:30:39 -04:00
Brandon Thomas d61a21c922 Merge pull request #90 from dylanmtaylor/feat/riscv-build
ci: Add RISC-V Linux build
2026-10-10 12:30:37 -04:00
Harrison O'Connor-Hoover 784c091ae9 Handle absent installer registry values in Windows smoke checks (#24) 2026-10-10 12:30:29 -04:00
Brandon ThomasandClaude Opus 5.5 efcfd08822 Review fix-up: align the riscv64 job with the other Linux jobs
- Pin actions/checkout and upload-artifact to the SHAs the other jobs use
  (v7.0.1, v7.0.2); add rust-cache; use the same version-override command.
- Run on ubuntu-24.04 (ubuntu-26.04 is not a known hosted label) and take the
  ports suite from /etc/os-release; 22.04's riscv64 ports lag its amd64
  updates and the multiarch install fails there.
- Add linux-riscv64 to the release job's needs so release-linux-riscv64 is
  downloaded before the draft release is built.
- Drop the unused libasound2 dependency, the linker -L flags and the
  qemu-static symlink; package.sh's emulator check is the smoke test
  (QEMU_LD_PREFIX), and quote $EMULATOR there.
- Move the job above the FreeBSD comment; list riscv64 in docs/releasing.md.

Verified: the job's steps in an ubuntu:24.04 container build, package and
run gridcraft-cli --version under qemu-riscv64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:29:33 -04:00
Brandon Thomas cee21c64ec Merge remote-tracking branch 'origin/main' into feat/riscv-build 2026-10-10 12:29:27 -04:00
Harrison O'Connor-Hoover ce1e2d1cee Offer a machine-wide optional Windows desktop shortcut (#24) 2026-10-10 12:29:03 -04:00
Brandon ThomasandClaude Opus 5.5 c67931cff6 Review fix-up: protect and cap text box edits
shape.setText refuses edits on a protected sheet (drawing objects are
locked; there is no edit-objects allowance yet) and text longer than
32,767 characters, like a cell. The editor doesn't open on a protected
sheet and its TextEdit carries the same character limit. Tests for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:23:51 -04:00
Brandon ThomasandClaude Opus 5.5 20e39fa3cd Review fix-up: clip blank ODS repeats past the sheet edge, struct-update Cell literal
Blank cell and row repeats that run past 16384 columns / 1048576 rows
(padding from writers with larger sheets) are clipped instead of
rejecting the file; any data or merge past the edge is still an error.
convert() builds its Cell with ..Cell::value(value) so new Cell fields
take their defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:22:51 -04:00
c4fd49afc2 Review fix-up: dedupe CJK fallback load, wider Linux/macOS font paths
- Load each fallback font file once: the region-merged Noto Sans CJK .ttc
  found for Han also covers Hangul (and Tahoma covers Thai and Hebrew), so a
  later script whose candidate file is already loaded reuses it instead of
  loading tens of MB again. New test: no two fallback faces share a file.
- Fold in the Linux CJK paths from #103: Fedora google-noto-cjk/, Arch
  noto-cjk/ and wenquanyi/, Debian truetype/wqy/wqy-microhei.ttc, the SC
  .otf builds, /usr/local, plus SimSun on Windows. Document the .ttc face
  indices (JP 0, KR 1, SC 2, TC 3, HK 4); Hangul uses the KR face.
- macOS: Hiragino Sans (ヒラギノ角ゴシック W3) and Hiragino Sans GB come
  before PingFang.ttc, which recent macOS no longer ships in /System.
- Comments promise glyphs instead of tofu, not correct rendering.
- fallback_covers_its_script compared has_glyphs, which is a false negative
  when the owning face also draws the replacement glyph (DejaVu Sans as UI
  font owns Hebrew); compare laid-out glyphs with the tofu glyph instead.

Co-authored-by: eyouhi <47080137+eyouhi@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:22:11 -04:00
Brandon Thomas 9365c5a9c6 Merge remote-tracking branch 'origin/main' into pr-17 2026-10-10 12:21:40 -04:00
Brandon ThomasandClaude Opus 5.5 c975b5dc75 Review fix-up: uniform, plot-clamped spline smoothing
smooth_run gives every segment the same number of sub-segments (cap / n, 1..100) so long
series are smoothed end to end within the point cap, and clamps the Catmull-Rom curve to
the plot box. List "smooth" in chart.addElement's params doc, drop the smooth_demo
example, and use the test sheet's own name in series refs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:21:35 -04:00
Brandon ThomasandClaude Opus 5.5 26b3db78db Merge codex/xlsb-import (with origin/main) into codex/ods-import
Resolves the shared integration points for both formats: combined sniff,
file.open spec, file filters, MCP description, docs. open_bytes now lets
sniffed content (ODS or XLSB) win over the extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:20:30 -04:00
Harrison O'Connor-Hoover 42854f2fb9 Preserve array-sensitive XLSX formulas (#64) 2026-10-10 12:19:52 -04:00
Brandon ThomasandClaude Opus 5.5 4955382a07 Review fix-up: control-protocol doc typo, tolerant XLSB phonetic-run tail
PhRun is 10 bytes per [MS-XLSB] 2.5.103 (four u16 fields plus a 16-bit
phType/alcH/unused1 word), so the documented size is kept; since the
RichStr ends its record and phonetic data is not imported, bytes after
the documented runs are now ignored instead of rejecting the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:19:33 -04:00
Brandon ThomasandClaude Opus 5.5 86328ec843 Review fix-up: ignore the wall-clock scaling test by default; check its values every run
recalc_with_range_formulas_scales_linearly is flaky under load, so it is #[ignore]d (run with
--ignored in release). A small range_formulas_recalc_and_bulk_edit_correctly test keeps the
helper's value check in every run. The helper reads Instant::now, so it allows
clippy::disallowed_methods (required once #125's clippy.toml lands).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:18:59 -04:00
Brandon Thomas 435a750bba Merge remote-tracking branch 'origin/main' into pr-62 2026-10-10 12:16:17 -04:00
Brandon ThomasandClaude Opus 5.5 ff77dea87f Review fix-up: bound Switch Row/Column on whole-column chart refs
Clip a file chart's series block to the sheet's used range and refuse blocks over the
insert.chart cell cap, so whole-column references can't build ~1M series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:16:09 -04:00
Brandon ThomasandClaude Opus 5.5 699b95288a Review fix-up: switch to Home only when the legacy keytip prefix changes
ribbon_keys ran ui.ribbonTab every frame while the prefix was E/O/OC,
forcing the Home tab continuously. Run it once on the prefix change and
add a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:14:34 -04:00
Dylan M. Taylor d3b8929fde ci: add RISC-V Linux build 2026-10-10 12:14:29 -04:00
Brandon ThomasandClaude Opus 5.5 deecb18f5d Review fix-up: skip pre-releases, prune the Pages repo, read-only pages in build
- Skip the build when the published release is a pre-release, and refuse a
  manual tag with a pre-release suffix or a release GitHub marks as one, so
  rc builds never reach the stable branch.
- flatpak build-update-repo --prune --prune-depth=3 so the re-mirrored
  Pages repository does not grow without bound.
- The build job only needs pages: read for configure-pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:11:51 -04:00
Brandon Thomas ce2cade393 Merge remote-tracking branch 'origin/main' into pr81 2026-10-10 12:11:24 -04:00
Brandon Thomas 11472b340b Merge remote-tracking branch 'origin/main' into fix66 2026-10-10 12:11:09 -04:00
Brandon ThomasandClaude Opus 5.5 2f9fce88b0 Review fix-up: allow the desktop logger's clock read under the new disallowed-methods lint
The logger (apps/gridcraft/src/logging.rs, merged from main) reads SystemTime::now; the desktop
app never runs on wasm, so allow the lint at that call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:10:59 -04:00
Brandon Thomas 7712eb1b0e Merge origin/main into fix/rich-table-clipboard 2026-10-10 12:09:28 -04:00
Brandon Thomas c5a8cf28fe Merge origin/main into codex/text-box-editing
# Conflicts:
#	apps/gridcraft/src/native_menu.rs
#	crates/engine/src/tests.rs
2026-10-10 12:08:36 -04:00
Brandon Thomas c607fe1627 Merge origin/main into fix/thai-hebrew-font-fallback 2026-10-10 12:05:14 -04:00
Brandon Thomas cecbd6cfb3 Merge origin/main into codex/ribbon-keytips 2026-10-10 12:05:04 -04:00
Brandon Thomas ea9d4c6b32 Merge origin/main into codex/xlsb-import 2026-10-10 12:04:33 -04:00
Brandon Thomas 11a921becb Merge remote-tracking branch 'origin/main' into pr-116 2026-10-10 12:04:22 -04:00
Brandon Thomas 15e0f0b6be Merge remote-tracking branch 'origin/main' into pr125 2026-10-10 12:04:21 -04:00
Brandon Thomas 1ded850ee6 Merge pull request #89 from dylanmtaylor/chore/update-actions
chore: bump github actions to fix Node 20 deprecation warnings
2026-10-10 11:57:45 -04:00
Brandon Thomas bd0ccd41b5 Merge pull request #35 from harrisonoconnorhover/fix/ribbon-dropdown-chevron
Fix missing dropdown arrows on large ribbon buttons (#26)
2026-10-10 11:57:44 -04:00
Brandon Thomas 2ad184c2d6 Merge pull request #44 from harrisonoconnorhover/codex/merged-cell-editing
Fix disappearing text when editing merged cells (#40)
2026-10-10 11:57:43 -04:00
Brandon Thomas 886fb0098b Merge pull request #46 from harrisonoconnorhover/codex/header-autofit
Fix header double-click AutoFit for selected columns and rows (#42)
2026-10-10 11:57:42 -04:00
Brandon Thomas 2cb5405b20 Merge pull request #47 from harrisonoconnorhover/codex/popup-dismissal
Fix worksheet popups staying open after outside clicks (#41)
2026-10-10 11:57:42 -04:00
Brandon Thomas 34ed83d13f Merge pull request #8 from switch87/desktop-log-file
Desktop: write log records to standard error and a rotating log file

# Conflicts:
#	apps/gridcraft/src/main.rs
2026-10-10 11:57:41 -04:00
Brandon Thomas 0feb404c68 Merge pull request #22 from DWAA1660/fix/windows-dx12-startup
Default Windows startup to DirectX 12
2026-10-10 11:57:31 -04:00
Brandon Thomas 0796c50816 Merge pull request #75 from DWAA1660/fix/71-snapshot-instructions
Fix the documented offscreen snapshot command (#71)
2026-10-10 11:57:30 -04:00
Brandon Thomas 674d422dc0 Merge pull request #77 from DWAA1660/fix/73-vertical-scrollbar
Add the missing vertical worksheet scrollbar (#73)
2026-10-10 11:57:29 -04:00
Brandon Thomas e165db1ffd Merge pull request #74 from DWAA1660/fix/70-inherit-cell-formatting
Preserve whole-sheet formatting when entering empty cells (#70)
2026-10-10 11:57:28 -04:00
Brandon Thomas f8b98775b8 Merge pull request #96 from harrisonoconnorhover/codex/system-theme
Add System appearance alongside Light and Dark themes
2026-10-10 11:57:27 -04:00
Brandon Thomas 38369a7566 Merge pull request #106 from harrisonoconnorhover/codex/border-previews
Preview border presets and fix shared-edge removal
2026-10-10 11:57:27 -04:00
Brandon Thomas 7f6739e665 Merge pull request #119 from agammann/fix/text-to-columns-escaped-quotes
Preserve doubled text qualifiers in Text to Columns
2026-10-10 11:57:26 -04:00
Brandon Thomas f736286f24 Merge pull request #50 from LloydNicholson/rotated-wrap-text
Issue #48: rotated text with Wrap Text draws upright (grid and print)

# Conflicts:
#	crates/ui-egui/tests/interaction.rs
2026-10-10 11:57:25 -04:00
Brandon Thomas 0aaaf9aeab Merge pull request #53 from LloydNicholson/general-number-fit
Issue #51: General numbers fit by the cell's own font, not 11pt
2026-10-10 11:57:15 -04:00
Brandon Thomas 7f9d77a12a Merge pull request #60 from MarekSpodBiedry/issue-58
ROUND: keep 15 significant digits when asked for 15+ digits, so ROUND(0.1+0.2,15) is 0.3 (#58)
2026-10-10 11:57:14 -04:00
Brandon Thomas 882a19568e Merge pull request #68 from LloydNicholson/fix/date-1900-feb-29
Issue #124: DATE(1900,2,29) is serial 60, as in Excel
2026-10-10 11:57:14 -04:00
Brandon Thomas 453882c6fe Merge pull request #67 from LloydNicholson/fix/sumif-sum-range-resize
Issue #123: SUMIF/AVERAGEIF size the sum range like the criteria range
2026-10-10 11:57:13 -04:00
Brandon Thomas 62d84cbbf5 Merge pull request #65 from MarekSpodBiedry/issue-63
XLSX: write _xlpm. prefix on LET/LAMBDA parameter names, strip it on read (#63)
2026-10-10 11:57:12 -04:00
Brandon Thomas 54df7e1fc5 Merge pull request #115 from nihilist-narwhal/feat-spill-range-operator
Formulas: the spill range operator (A1#)
2026-10-10 11:57:11 -04:00
Brandon Thomas c30a720998 Merge pull request #114 from nihilist-narwhal/fix-defined-name-entry
Names: R1C1-style names, Create from Selection bottom/right, LAMBDA name case
2026-10-10 11:57:10 -04:00
Brandon Thomas 986b6c6ea6 Merge pull request #113 from nihilist-narwhal/fix-defined-name-scopes
Names: keep sheet-level names with their sheet on move, copy and delete
2026-10-10 11:57:09 -04:00
Brandon Thomas 1cdee3ee02 Merge pull request #111 from nihilist-narwhal/fix-protection-commands
Protection: enforce locked cells for every command, keep hidden formulas hidden
2026-10-10 11:57:08 -04:00
Brandon Thomas a678c5d25f Merge pull request #34 from MarekSpodBiedry/issue-21
Fix: inserting or deleting rows, columns or cells cancels copy/cut mode (#21)
2026-10-10 11:57:08 -04:00
Brandon Thomas e92a67bbba Merge pull request #112 from nihilist-narwhal/fix-shifted-cell-references
Insert/Delete Cells: adjust references like Excel
2026-10-10 11:57:07 -04:00
Brandon Thomas 1b20cb873d Merge pull request #85 from nihilist-narwhal/xlsx-spill-cached-errors
XLSX: cache #SPILL! and #CALC! as #VALUE! so Excel can open the file
2026-10-10 11:57:06 -04:00
lloydandClaude Opus 5.5 3509bcfd87 macOS: open documents from Finder, the Dock and open -a (#69)
Double-clicking a workbook, Open With, dropping on the Dock icon and
`open -a GridCraft book.xlsx` send the app a kAEOpenDocuments Apple event
instead of command-line paths. winit 0.30 doesn't handle it, so AppKit
answered "GridCraft cannot open files in the Excel Workbook format".

Register the handler with the audited fmv-macos-events crate that PdfCraft
and PhotoCraft already use: it does the Objective-C interop (unsafe, which
this workspace forbids) behind a safe main-thread API and leaves winit's
delegate alone. Opened paths go through SheetApp::open_path; a quit event
closes the window. The registration lives in main() until the event loop
returns, so the event that launched the app is caught too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:17:14 +02:00
Fabio LussiandClaude Opus 5.5 ded67f51d2 fix(fill): clamp month and year date series to the month's last day
Fill Series by month used the previous cell's day as an overflowing DATE()
day, so Jan 31 + 1 month became Mar 3 and February was skipped. The same
overflow affected year steps from Feb 29 and the autofill month pattern.

Each item is now computed from the start date plus n months and the day is
clamped to the target month's length (Jan 31, Feb 28, Mar 31, Apr 30), so
the clamp does not compound. Leap years are handled.

Fixes #132

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:28:07 +02:00
Fabio LussiandClaude Opus 5.5 61b264ac21 functions: MIN() accepts zero arguments
Excel documents the first argument of MIN as optional and returns 0
when there are no numbers. MIN was registered with a minimum of one
argument, so =MIN() returned #VALUE!. The aggregate already returns 0
for an empty list, so only the registered arity needed to change.

Fixes #138

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:36 +02:00
nihilist-narwhal f16fed557e Charts: write and read chartex parts
Histogram, box & whisker, waterfall, funnel, treemap and sunburst charts
were written as clustered columns, and reading skipped such charts in
files made by Excel, which keeps them as cx:chartSpace (chartex) parts.

The writer now writes these kinds as chartEx parts, as Excel lays them
out: the data goes through hidden _xlchart.v1.N workbook names, each
part comes with chart style and colour parts, and the drawing frames it
in mc:AlternateContent with a text-box fallback. Fills are written out
(series in theme accents, waterfall decreases and treemap/sunburst boxes
in their own, a white chart area), so they don't depend on the style
part. The reader reads the parts back (resolving the _xlchart names,
which aren't kept as names) with data, title, axis titles, legend,
labels and colours, and follows mc:AlternateContent in drawings.
2026-10-10 06:25:24 -05:00
nihilist-narwhal e5d34fd63a Recalc: dynamic arrays don't spill in tables
Excel doesn't support spilled arrays in tables: a formula such as
=SEQUENCE(2) entered in a table cell shows #SPILL! ("Spill range in
table"). GridCraft spilled it over the table's cells. An array whose
formula is in a table is now blocked, both when it is computed and when
it is written back.
2026-10-10 05:40:36 -05:00
nihilist-narwhal 8011191824 Go To: a table name selects the table's data
Go To (and the Name Box) with a table's name selected the whole table,
header and totals rows included. In Excel a table's name stands for its
data rows, as in formulas (=SUM(Table1) adds only the data).
2026-10-10 05:30:49 -05:00
nihilist-narwhal 985257b166 Tables: typing in the header row renames the column
Editing a table's header cell changed only the cell: the column kept its
old name, so =SUM(Table1[a]) still worked after the header read "zz",
and saving wrote the old names back into the header row, undoing the
edit on reopening. As in Excel, the column now takes the new header;
an emptied header becomes ColumnN and a repeated one gets a number
("a2"), written back into the cell, and structured references follow
the new name.
2026-10-10 05:30:49 -05:00
Harrison O'Connor-Hoover 8846dadc52 Fix #128: preserve references when cutting cells across sheets 2026-10-10 06:11:29 -04:00
Brandon Thomas c1b1eb6757 Merge pull request #109 from harrisonoconnorhover/codex/fast-drag-selection
Preserve quick selection drags and clarify the fill handle
2026-10-10 06:07:57 -04:00
Brandon Thomas 6f52f1fd3d Merge pull request #94 from harrisonoconnorhover/codex/keyboard-navigation
Fix Enter navigation returning to the last clicked cell
2026-10-10 06:07:56 -04:00
Brandon Thomas 14b59a71f5 Merge pull request #97 from harrisonoconnorhover/codex/file-menu
Add a visible File menu to the shared ribbon
2026-10-10 06:07:56 -04:00
Brandon Thomas 1c6cb3bce9 Merge pull request #95 from harrisonoconnorhover/codex/screenshot-timeout-advice
Recommend supported recovery steps after a screenshot timeout
2026-10-10 06:07:55 -04:00
nihilist-narwhal 97f7dca886 Recalc: INDIRECT and OFFSET see spills laid out in the same pass
A formula whose references are only known while evaluating isn't in the
dependency graph, so when it read a spill area before the array was laid
out in the same pass, nothing evaluated it again. With the full
recalculation now laying every spill out afresh, =INDIRECT("F7") over
a spill moved by an inserted row read 0 after Calculate Now. Such
formulas are evaluated once more when a pass computed any array.
2026-10-10 04:59:27 -05:00
nihilist-narwhal be8e379ca0 Recalc: formulas over a blocked spill see #SPILL!
A dynamic array that couldn't spill was only rejected when results were
written back, after formulas evaluated later in the same pass had read
the array as if it had spilled. With 1s in A1:C3 except B2 =
SEQUENCE(2,2,9) (blocked), =SUM(A1:C3) gave 47 after a full
recalculation (opening a file, Calculate Now) instead of #SPILL!; a
reader through OFFSET kept the wrong value even when entered one cell
at a time. The spill area is now checked as soon as the array is
computed, so the anchor is #SPILL! for everything after it.
2026-10-10 04:45:41 -05:00
nihilist-narwhal 303eabaad1 Recalc: an array that read another array's old spill is evaluated again
When two dynamic arrays were recalculated in the same pass and the
first read the second's spill area before the second was evaluated, it
read the old values. Formulas reading a spill area that changed are
evaluated again, but dynamic arrays were left out of that second round
(only to keep an array from re-queuing itself), so with D11 =
SEQUENCE(1,3,A1) and G5 = UNIQUE(VSTACK(F9:F12,A1)), changing A1 from 1
to 10 left 3 in G6 instead of 12. Only the array itself is left out
now.
2026-10-10 04:42:51 -05:00
nihilist-narwhal 880163d135 Recalc: a spill that goes away unblocks the arrays it blocked
With =SEQUENCE(5) in F8 and =SEQUENCE(1,3) in E10 (blocked by F10),
typing 0 into F9 blocks F8's array, which frees F10, but E10 stayed
#SPILL! until a full recalculation. When a formula stops spilling over
an area, #SPILL! anchors above or to the left of it are evaluated again.
2026-10-10 04:36:06 -05:00
nihilist-narwhal e51c1d98fb Recalc: clearing a distant blocker unblocks the spill
Clearing a cell re-evaluated #SPILL! anchors only within 64 rows and
columns above and to the left of it, so =SEQUENCE(100) in A1 stayed
#SPILL! after the blocker in A90 was cleared. Emptied cells now look
for blocked anchors any distance above or to the left, with one pass
over the formulas per sheet however many cells were cleared.

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

Fixes #133.
2026-10-10 04:27:44 -05:00
nihilist-narwhal fc2f00bd50 Recalc: a range ending in INDEX depends on the cells it spans
=SUM(C3:INDEX(B9:C11,2,2)) adds C3:C10, but only C3 and B9:C11 were
recorded as its precedents, so typing into C5 left the total unchanged
until a full recalculation. A range whose ends are function results now
depends on the box spanned by the references inside it, which contains
any cell those functions can return.
2026-10-10 04:24:42 -05:00
nihilist-narwhal 48910d71d2 Recalc: replacing or clearing a spilling formula removes its spill
Typing a value over a dynamic array formula, or clearing it, left the
values it had spilled: with =SEQUENCE(3) in A1, typing 2 into A1 kept
2 and 3 in A2:A3, and =SUM(A1:A3) gave 7. Only formulas are evaluated
again, so the old spill of a cell that no longer holds a formula was
never written back. It is now removed when the formula goes, and the
formulas reading its area are recalculated.
2026-10-10 04:24:41 -05:00
nihilist-narwhal 5ad3c6eed3 Paste Special operations: keep text, errors and formulas
Add, Subtract, Multiply and Divide replaced every cell that didn't hold
a plain number with the pasted value: multiplying a column by 1 turned
numbers stored as text, headers and #N/A all into 1, and a formula into
a constant. As in Excel, numbers stored as text now take part (the
usual way to turn them into numbers), other text and errors stay, and
a formula is kept and combined with the pasted number: =(B3*2)*1.
2026-10-10 03:51:49 -05:00
nihilist-narwhal 60b60619b2 AutoFill: refuse a source as large as the sheet
The target was capped at 5 million cells but the source wasn't: with the
whole sheet as source and A1 as target, AutoFill read all 17 billion
source cells one column at a time and never finished.
2026-10-10 03:46:15 -05:00
nihilist-narwhal e5af1fcbff Spilled blank cells show 0
A formula's result that is a blank cell shows 0, but blanks inside an
array result spilled as blank cells: =A1:A3, or FILTER or SORT of a
range with blanks, left empty cells that ISBLANK saw as blank and
COUNT skipped. Excel shows 0 in each. GROUPBY and PIVOTBY keep the
blank cells of their own layout.
2026-10-10 03:19:45 -05:00
nihilist-narwhal 62b17379e3 Ranges keep their size past the used range
A multi-cell reference was read only up to the sheet's used range, so
the array it gave depended on what else was on the sheet:
ROWS(A1:A10*1), COUNTBLANK(A1:A10) and SUMPRODUCT(--(A1:A10="")) came
out smaller than in Excel, and changed as cells were entered elsewhere
without the formula recalculating. References of up to 16,384 cells now
keep their size; larger ones (A:A, ranges reserved for data to come
such as $A$2:$A$50000) still stop at the used range, so that formulas
over them stay fast.
2026-10-10 03:19:45 -05:00
nihilist-narwhal 1b0b2009aa Current region: grow past 10,000 rows, in linear time
current_region (Ctrl+A, and the range that sort, filter, AutoSum,
tables and Get Data use when one cell is selected) grew the region one
ring of cells per step and stopped after 10,000 steps, rescanning every
cell of the ring each time. With 20,000 rows of data and one cell
selected, data.sortDescending sorted only the first 10,001 rows and
left the rest as they were; a 100,000-row region took 30 s and was cut
at row 10,001.

It now grows one side at a time while the line next to that side has
data (diagonals included), checking only the cells of that line, with
no step limit.
2026-10-10 03:17:10 -05:00
nihilist-narwhal a059c8e196 Repeated headers: number pivot fields and table columns in linear time
Each header was checked against every name before it, once per suffix
tried, so a source whose headers repeat (a row of thousands of equal
labels, such as check boxes) took cubic time: 4,000 columns took 28 s
in Recommended PivotTables, and a row of 16,384 hung the app, in
PivotTables, in Insert Table and in Get Data from Table/Range. Taken
names and the next suffix of each header are now kept in hash maps, in
the pivot source, in table creation and in the XLSX pivot cache; the
names are the same as before.
2026-10-10 03:05:48 -05:00
nihilist-narwhal 7fece30d49 Remove Duplicates: compare only the part of the range with data
Given whole columns ("range":"D:D"), Remove Duplicates compared a
million blank rows and reported 1,048,573 removed duplicates; given the
whole sheet it ran for minutes, as it also did on a table resized to
the whole sheet. Rows and columns past the used range are blank, so
they are left out: the result is the same and the counts match the
data.
2026-10-10 02:49:31 -05:00
nihilist-narwhal 684567cc9a Whole-sheet selection: Fill and Remove Duplicates stay within the data
With the whole sheet selected (Ctrl+A on an empty sheet), Fill Down,
Right, Up and Left tried to fill all 17 billion cells and ran out of
memory, and Remove Duplicates scanned every row for minutes. Fill Up
and Fill Left also took their source row or column before narrowing the
selection to the data, so they filled up to row 1,048,576 even when the
sheet had data.

Whole rows and columns now narrow to the used range before anything
else (nothing to do on an empty sheet), Remove Duplicates works on the
active cell of an empty sheet, and a fill of more than 5,000,000 cells
is refused, as Auto Fill already does.
2026-10-10 02:33:26 -05:00
nihilist-narwhal 439df2ae3a selection.move: Ctrl+arrow without a direction stays put
jump() searched for the next filled cell by stepping (dr, dc); with
neither set, the step never moved, so selection.move {"jump":true}
from a blank cell of a sheet with content looped forever (found with
an agent call that left out dr and dc). It now returns the cell it
started from.
2026-10-10 02:20:21 -05:00
lloydandClaude Opus 5.5 e893621d76 Web app: committing a cell no longer traps on the clock
Every edit goes through cmd::commit, which read std::time::Instant::now()
up front for GRIDCRAFT_PROFILE timing, whether profiling was on or not.
wasm32-unknown-unknown has no clock: Instant::now panics there, and with
panic=abort the browser reports `RuntimeError: unreachable`. So in the
web app, typing 1 into A1 and pressing Enter trapped (#118). The native
apps and the CLI have a clock and were unaffected.

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

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

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

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

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

The result is now fitted to the range: a single row or column repeats
across it, cells past the result show #N/A, and a larger result is cut
off. Reading drops the cached values inside the range, entering clears
the range, and saving caches its values. The formula bar shows the
formula in braces, {=B2:B3*2}.
2026-10-10 01:53:55 -05:00
agammann 06cb377c54 Fix escaped quotes in Text to Columns 2026-10-09 22:47:38 -07:00
nihilist-narwhal bf1381253f Charts: scatter and bubble charts with text X values number the points
A scatter or bubble chart whose X values are text (a first column of
labels) drew no points, as none of its X values were numbers. Like
Excel, such X values are now ignored and the points numbered 1..n.
2026-10-09 23:31:26 -05:00
nihilist-narwhal fddd08bec3 Charts: Switch Row/Column on charts read from a file
chart.switchRowColumn did nothing on a chart read from a file: only
charts made by insert.chart keep their source range. Without one, the
command now takes the block the series' names, categories and values
span (when they're all on one sheet), and keeps it.
2026-10-09 23:31:26 -05:00
nihilist-narwhal 925bf22e24 Charts: combo and stock charts keep their kind in XLSX
A combo chart whose series have no kind of their own (as insert.chart
makes them) was written as one column group, and a stock chart as
clustered columns, so both reopened as clustered columns. Combo series
are now written as drawn (the first as columns, the others as lines),
and stock charts as a stockChart group (as lines when they don't have
the 3 or 4 series a stock chart needs).
2026-10-09 23:31:18 -05:00
nihilist-narwhal 9c29ef3fff Formulas: the spill range operator A1#
formula::parse("Sheet1!$D$1#") failed with "unknown error literal".
A # right after a cell reference is now the spill range operator: it
evaluates to the anchor's current spill range, or #REF! when the cell
doesn't spill (or its spill is blocked). Its users depend on the
anchor, so they recalculate when the spill grows or shrinks. Names can
use it.

Files have no # operator: like Excel, the writer stores A1# as
_xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#.
2026-10-09 23:29:35 -05:00
nihilist-narwhal d4d38fd224 Names: calls of LAMBDA names keep the name's case
The parser upper-cases function names, so =Double(4) showed as
=DOUBLE(4). Calls of LAMBDA names are now spelled like the name when
a formula is entered and when a file is read.
2026-10-09 23:27:39 -05:00
nihilist-narwhal b28b4b729f Names: Create from Selection uses bottom and right, keeps existing names
formulas.createFromSelection ignored bottom and right, and replaced any
workbook name with the same label without asking. Bottom-row and
right-column labels now name the cells above and to their left. An
existing name is kept and listed in the result's skipped, unless
replace is set (Excel asks; a command doesn't open dialogs).
2026-10-09 23:27:38 -05:00
nihilist-narwhal 8029433b1c Names: refuse R1C1-style names
valid_name refused R and C but took R1C1, R2, C3 and RC, which Excel
refuses because they read as R1C1 references.
2026-10-09 23:27:38 -05:00
nihilist-narwhal 68fac6d861 Insert/Delete Cells: adjust references like Excel
Delete Cells built the references' edit and then discarded it, so
references into the deleted cells didn't become #REF!, and Insert Cells
treated the shift as a move of everything below, so =SUM(A1:A5) didn't
grow to A1:A6 when A3 was inserted.

Two new structural edits, InsertCells and DeleteCells, adjust a
reference like inserting/deleting rows (columns) when it lies within the
shifted columns (rows), and leave it as it is otherwise, as Excel does.
Formulas, defined names, chart series, conditional-format, validation,
table and print-area ranges, notes, links and sparklines follow.
2026-10-09 23:27:21 -05:00
nihilist-narwhal e294345599 Protection: keep hidden formulas hidden
On a protected sheet a formula formatted Hidden still showed with Show
Formulas on, matched Find in formulas, and showed in the formula bar
when a cell of its spill range was selected.
2026-10-09 23:25:58 -05:00
nihilist-narwhal 2a3d1ffd7f Protection: refuse every edit of a locked cell on a protected sheet
Only cell.set checked protection. The check now runs where every edit
is committed: an edit that changes the value or formula of a locked
cell on a protected sheet is refused with Excel's message, whichever
command made it (clear, paste, fills, setValues, replace, goal seek,
sort, insert/delete cells...).

Inserting and deleting rows and columns follow the protection's
options, and deleted lines must not hold locked cells.
2026-10-09 23:25:58 -05:00
nihilist-narwhal b53a190ea1 Names: keep sheet-level names with their sheet
Moving a sheet left its sheet-level names on whatever sheet took its
index, and copying it shifted nothing and copied no names. Moving now
re-indexes name scopes, and a copy gets copies of the sheet's names,
referring to the copy, as in Excel.

Deleting a sheet left names that referred to it pointing at nothing;
they now become #REF!. formulas.deleteName honours scope, and the Name
Manager's Delete passes it.
2026-10-09 22:52:49 -05:00
Harrison O'Connor-Hoover 198e8ecb05 Issue #28: retain quick selection drag endpoints and clarify fill handle 2026-10-09 23:05:46 -04:00
Harrison O'Connor-Hoover b2386e1c23 Issue 30: preview border presets and clear shared edges 2026-10-09 22:45:41 -04:00
neko233-com 12bfdb0798 i18n: add Chinese, Korean and Russian interface languages 2026-10-10 10:24:09 +08:00
harrisonoconnorhover c51e51fe7b #92: import XLSB worksheet data and saved formula results 2026-10-09 22:19:36 -04:00
harrisonoconnorhover da0bf32d8a #16: import ODS worksheet data without overwriting source files 2026-10-09 22:09:09 -04:00
harrisonoconnorhover daf4591da0 Expose basic file operations in the shared ribbon (#93) 2026-10-09 21:35:53 -04:00
harrisonoconnorhover 922847a1ed Add system-following interface appearance (#72) 2026-10-09 21:35:21 -04:00
harrisonoconnorhover 91d44508b1 Correct screenshot timeout recovery advice (#78) 2026-10-09 21:27:18 -04:00
harrisonoconnorhover 698a2a8009 Keep keyboard navigation separate from pointer clicks (#87) 2026-10-09 21:26:06 -04:00
Brandon ThomasandClaude Opus 5.5 13664fe66b Bump version to 0.4.0 (from 0.3.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:14 -04:00
Dylan M. Taylor 2b1da77e46 chore: bump github actions to fix Node 20 deprecation warnings 2026-10-09 20:08:28 -04:00
nihilist-narwhal 311b1a593f Contributors: add nihilist-narwhal 2026-10-09 17:14:59 -05:00
nihilist-narwhal cd083cfc50 XLSX: cache #SPILL! and #CALC! as #VALUE!
They are not error codes of the file format, and Excel refuses to open a
file whose cached values contain them ("Parameter error"). Any workbook
with a blocked spill could not be opened in Excel. Excel caches these
errors as #VALUE! itself.
2026-10-09 17:14:59 -05:00
harrisonoconnorhover 628732ef58 Enable inline editing of worksheet text boxes (#56) 2026-10-09 17:47:58 -04:00
Eduardo Soares f424d7d56b i18n: Brazilian Portuguese interface language (#79)
Adds PtBr to the interface-language system from #31: a PORTUGUESE table
covering the same 259 chrome strings as JAPANESE (a test keeps the two
tables in step), Excel's pt-BR wording throughout, "pt"/"pt-br" codes
(serde-stored as "pt"), Português (Brasil) in the switcher, the
app.language.portuguese command, and first-run detection of a pt-BR
system locale via the existing tag reduction.
2026-10-09 18:18:57 -03:00
Dylan M. Taylor 4d46b9170b ci: publish Flatpak repository to Pages 2026-10-09 17:14:43 -04:00
DWAA1660andCodex 165e6c8a3b M2: add a vertical worksheet scrollbar (#73)
Reserve a strip beside the virtual grid for a theme-aware vertical
scrollbar. Support paging, dragging and wheel scrolling without changing
the active cell. Keep the scroll extent fixed during thumb dragging.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 14:46:29 -06:00
DWAA1660andCodex 96b3ab3244 M2: document the supported offscreen snapshot example (#71)
Point both contributor instruction files to the UI crate's snapshot
example and identify it as a command to run from a source checkout.
Verified the documented command produces a readable sales dashboard PNG.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 14:37:52 -06:00
DWAA1660andCodex d7174f8297 M2: preserve inherited formatting when entering empty cells (#70)
Resolve input styles from the destination cell, row or column before
parsing. Preserve inherited alignment, borders and number formats for
single-cell and bulk entry, including text paste and array formulas.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 14:33:43 -06:00
MarekSpodBiedry b8b3ba0b0e XLSX: write _xlpm. prefix on LET/LAMBDA parameter names, strip it on read (#63) 2026-10-09 20:57:44 +02:00
lloydandClaude Opus 5.5 631a49ec30 Recalc: linear in the number of range formulas
Opening a workbook with a SUM(E:J) on each of 50,000 rows took 9 s
(1.7 s at 25,000, 0.4 s at 12,500), and editing a cell that 50,000
VLOOKUPs read froze the app for 40 s or more. Two things grew with the
number of ranges:

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:44:18 +02:00
justinjohn0306 69709bd458 Line charts: smooth (spline) curves (issue #52)
Add support for Excel's "smoothed line" chart style. The smooth flag flows
model -> resolve -> chart render, persists through XLSX (<c:smooth val="1"/>),
and is toggled via the chart.addElement "smooth" element.

Rendering interpolates each gap-free run with a uniform Catmull-Rom spline
(0.5 tangent scale, 100 sub-segments per data segment), so the curve passes
through every data point and rounds the vertices — matching Excel's observed
behaviour. Long runs are still decimated before smoothing.

- model: Series.smooth (#[serde(default)], so old workbooks load unchanged)
- chart: SeriesData.smooth; smooth_run + catmull in render; wired through
  resolve and the sanitize() copy; draw_runs takes the flag (line + scatter)
- engine: chart.addElement element="smooth"
- xlsx: read/write <c:smooth val> instead of hard-coding val="0"
- tests: smooth_line_adds_interpolated_points_and_keeps_endpoints,
  smooth_line_splits_at_gaps, smooth_line_setting_round_trips
- examples/smooth_demo.rs: generates a straight+smooth side-by-side workbook

cargo xtask ci: all 7 steps pass.
2026-10-10 00:09:12 +05:30
lloydandClaude Opus 5.5 ecaead4c12 DATE(1900,2,29) is serial 60, as in Excel
serial_from_ymd worked out the real calendar date and then moved serials
up to 60 down by one. February 1900 has no 29th in the real calendar, so
DATE(1900,2,29) and DATEVALUE("2/29/1900") rolled over to 1 March (61),
and DATE(1900,3,0) gave 28 February (59). Excel's 1900 system keeps the
Lotus leap day: serial 60 is 29 February 1900, and DAY(60) already
said so.

The serial of the first of the month is now taken in Excel's calendar
(one lower up to February 1900), and the day, with any overflow, counts
on from there. So day and month overflow pass through serial 60 both
ways: DATE(1900,1,60), DATE(1900,4,-31) and DATE(1901,-10,29) are 60 too.
Month and day arithmetic is checked, so hostile values return None.

days_in_month_in gives February 1900 29 days in the 1900 system; EDATE
and EOMONTH use it, so EOMONTH(60,0) is 60 rather than the day before,
and the text parser uses it in place of its own special case. The 1904
system is unchanged (1900 is before its epoch).

Tests: serials around the leap day (overflow both ways, every serial
0-366 round-trips through its year, month and day), hostile months and
days, parsing 2/29/1900 and 1900-02-29, DATE/DATEVALUE/EDATE/EOMONTH
in the 1900 system and the 1904 system. The DATE, DATEVALUE and serial
cases fail before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:36:08 +02:00
MarekSpodBiedry 2c2d92ef6b ROUND: keep 15 significant digits when asked for 15+ digits, so ROUND(0.1+0.2,15) is 0.3 (#58) 2026-10-09 19:29:29 +02:00
lloydandClaude Opus 5.5 903bbb096d Issue #51: General numbers fit by the cell's own font, not 11pt
grid.rs measured the digit width once, for an 11pt cell font, and
display_text judged how many General digits fit from that whatever the
cell's size. 9pt marks such as 100 in columns with a stored width of 4
(common in mark sheets) came out as ##. The width is now measured per cell
with its own font and size, as print.rs already does; the shared 11pt
measurement and its paint_quadrant parameter go away.

Test: a 9pt 100 in a 28px column draws in full, while 123456789 next to it
still shows ####. Fails before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:23:15 +02:00
lloydandClaude Opus 5.5 ad6e77f6ef Issue #48: rotated text with Wrap Text draws upright (grid and print)
Narrow columns with tall, upright headers (mark sheets) set both a text
rotation and Wrap Text. The wrap path drew the text flat and returned before
the rotation was applied, so headers came out as 3-4 character fragments.

- Rotated text no longer takes the flat wrap path. Upright (+/-90 deg) text
  wraps to the row height, its lines stacking across the column; other
  angles keep one line, as before.
- Downward text was anchored at the bottom-left and ran out of the cell
  (over the row headers); it now starts at the top, one block-height in.
- Upright text no longer overflows into empty neighbours: its width is its
  line height, and the overflow repainted their gridlines.
- Print/PDF gets the same three fixes.

Tests: ui-egui interaction test (shapes drawn at +/-90 deg, inside a
one-column clip) and a print test (every text block on the page rotated,
a few long lines each). Each failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:13:40 +02:00
harrisonoconnorhover 1d2df6126b Dismiss worksheet popups on outside clicks and focus loss (#41) 2026-10-09 10:32:07 -04:00
harrisonoconnorhover 38c11f6fb9 Fix header double-click AutoFit for selected ranges (#42) 2026-10-09 10:23:09 -04:00
harrisonoconnorhover b36f919410 Fix editing and formula bar reads in merged cells (#40) 2026-10-09 09:51:08 -04:00
harrisonoconnorhover 56c32750a2 Add initial ribbon keytips and legacy shortcuts for #33 2026-10-09 09:44:04 -04:00
Brandon Thomas 51b531be3e Merge pull request #13 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:20:56 -04:00
justinjohn0306 6beb80ed36 Fonts: render Thai and Hebrew (and any non-Latin) instead of tofu (issue #32)
egui bundles only Latin glyphs, and the fallback chain appended just the
Han/Kana and Hangul faces, so Thai and Hebrew text rendered as tofu boxes
even though the text was read correctly.

Generalise the CJK fallback into a per-script fallback: every family now
also carries a Thai face (Leelawadee UI / Tahoma / Thonburi / Noto Sans
Thai) and a Hebrew face (David / Arial Hebrew / Noto Sans Hebrew). epaint
shapes the complex scripts itself via harfrust -- it only needed a face
that has the glyphs. The faces stay at the end of each chain, so Latin
still wins for Latin text.

The tests become script-general: a structural check that every family
ends with the loaded fallback faces, and a per-script render check that
the sample string resolves to a real glyph when the face is present.

Full bidi reordering of mixed LTR/RTL text is a separate epaint limitation
(no Unicode Bidi Algorithm); a pure other-script run is shaped correctly.
2026-10-09 17:06:36 +05:30
harrisonoconnorhover 51f3279135 Fix #23: copy selected cells as HTML tables 2026-10-09 05:56:08 -04:00
harrisonoconnorhover 28113c6c75 Fix #26: paint large ribbon dropdown chevrons 2026-10-09 05:28:48 -04:00
MarekSpodBiedry 4dd49f7b95 Fix: inserting or deleting rows, columns or cells cancels copy/cut mode (#21) 2026-10-09 10:45:50 +02:00
justinjohn0306 e8e010b446 i18n: switch the interface language (issue #20)
Add an interface-language preference. It defaults to the system's
language on first run (sys-locale, native only) and is switched by hand
from the View tab, next to Dark Mode, or with app.language.english /
app.language.japanese over the control channel. It is saved in ui.json.

Follows the filmcraft house convention: a Language enum and a
table of English-to-Japanese strings with English fallback, so coverage
can grow incrementally. Japanese chrome renders without any bundled
font: the CJK fallback faces already appended to every family cover
kana and kanji.

The ribbon chrome all flows through the small_button/big_button/
toggle_button/split_button/icon_button helpers and menu_items, so
translating there localises the whole ribbon in one place; the language
of the frame is read from egui context data (i18n::current). Command
ids, file names and document text are never translated.
2026-10-09 13:14:48 +05:30
justinjohn0306 7e24aa398a CI: satisfy newer clippy lints (toolchain drift, behavior-preserving)
Local clippy 1.96 flags pre-existing patterns the code has always had:
manual range-contains comparisons, a dead `|| true` filter and a
simplifiable boolean. None change behaviour; they only clear the lints
so `cargo xtask ci` passes on a current toolchain.
2026-10-09 13:08:28 +05:30
Xus BadiaandClaude Opus 5.5 be33eba7d1 macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the GridCraft colour, with GridCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~26 KB)
  and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
  positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it (no paths, users,
  disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:43:50 +02:00
justinjohn0306 891514892a CJK text renders as tofu: fall back to a system CJK font
A Japanese (or Chinese/Korean) workbook opened fine — the engine reads the
strings correctly — but every kanji and kana drew as a .notdef box, because
egui's bundled fonts carry no CJK coverage and nothing added a CJK face to the
fallback chain.

Append a system CJK face to every font family (UI, cell, serif, mono): a
Japanese face first for correct kana/JIS kanji, a Chinese face for breadth, and
a Hangul face for Korean. epaint picks the first face in a family that has the
glyph, so Latin still renders from the Latin fonts and only Han/Kana/Hangul
reach the CJK fonts. No font bytes are bundled (AGENTS.md): families are loaded
from the OS, and a machine with no CJK font simply keeps the old behaviour.

Tests: the CJK keys end every family chain (Latin stays first); and the
proportional family resolves 「カテゴリ」 to real glyphs, not the replacement
character — verified to fail without the fallback.
2026-10-09 10:13:44 +05:30
DWAA1660andClaude Opus 5.5 881bab8b6a Default Windows startup to DirectX 12
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app exits silently at launch. When WGPU_BACKEND is not
set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.

Same fix as storytold/designcraft#168 and storytold/filmcraft#179.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD
2026-10-08 19:40:12 -06:00
justinjohn0306 2276eabba8 File open: recognize Excel password-protected (CFB) files, report clearly (#11)
Excel-encrypted workbooks are CFB/OLE2 compound files (magic D0CF11E0),
not zip packages. sniff() now returns Format::Encrypted for them, and
open_bytes reports a clear password-protected message instead of the
cryptic 'not a valid zip package' error.
2026-10-08 21:04:07 +05:30
justinjohn0306 cbc2430f4f Objects: anchor modes — images/charts/shapes follow cells on sort (#5)
Excel anchors a floating object to the grid in one of three ways, stored in the
file as the drawing anchor's `editAs`: "move and size with cells" (twoCellAnchor,
the default), "move but don't size with cells" (oneCellAnchor / editAs="oneCell")
and "don't move or size with cells" (editAs="absolute"). GridCraft had no such
concept: xlsx import dropped `editAs`, export always wrote a plain twoCellAnchor,
and sorting/filtering never moved an object — so pictures stayed put when their
rows were reordered.

Model: add `AnchorMode` on `Anchor` (serde default = MoveAndSize, so existing
documents round-trip unchanged).

xlsx: read the anchor kind and `editAs` into the mode; write it back out
(editAs="oneCell"/"absolute" as Excel does).

Engine: sorting now moves objects anchored inside the sorted body with their
lines, re-fitting "move and size" objects, keeping "move but don't size"
objects' size, and leaving absolute ones pinned. Structural insert/delete
honours the same rule (previously it moved every object's cell unconditionally
and ignored shapes). New `object.setAnchorMode` command sets an object's mode;
`arrange.selectionPane` reports it.

Tests: sort moves images with their rows; absolute objects stay put across a
sort and a row delete; move-only keeps its size; the mode survives an xlsx
round-trip; the drawing fixture parses all three anchor kinds.
2026-10-08 17:58:53 +05:30
Gert Pellin 26f325ec15 Desktop: no eprintln! on the log and panic paths
eprintln! panics when standard error is a broken pipe (a GUI launch on
Windows), and a panic inside the panic hook aborts. The "no log file"
message goes through log::warn! (standard error only by then); the panic
hook's fallback writes to standard error with write_fmt and ignores the
error. Same fix as storytold/vectorcraft#465.
2026-10-08 13:01:16 +02:00
Gert Pellin 1c8d0b8fe5 Desktop: write log records to standard error and a rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded (a sample that failed to build, wgpu and naga
warnings), and panics a command's guard recovered from only reached
standard error. A launch from a desktop menu has no terminal either, so
there was nothing to attach to a bug report.

apps/gridcraft/src/logging.rs (no new dependency):
- standard error plus logs/gridcraft.log next to ui.json and prefs.json
  (~/.config/gridcraft/logs, or under $XDG_CONFIG_HOME); each launch
  rotates the previous log to gridcraft.1.log and .2.log, so the log of a
  run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for gridcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink
- runs with GRIDCRAFT_NO_PREFS (agents' test runs) log to standard error
  only, so they don't rotate away the user's own logs

The logger is installed first in main, followed by a panic hook that
reports every panic, caught or not, through log::error! (with a backtrace
when RUST_BACKTRACE is set). The file is opened after argument parsing
(--version leaves no file). The start-up messages in main and the
control channel's go through the logger. Documented in README.md (Logs).
2026-10-08 12:06:39 +02:00
184 changed files with 19324 additions and 1000 deletions
+176
View File
@@ -0,0 +1,176 @@
name: Publish Flatpak repository to Pages
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: "Published GitHub release tag to publish (for example, v1.2.3)"
required: true
type: string
permissions:
contents: read
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
build:
name: Build Flatpak repository
# Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
# here, and the validation step below refuses a manual tag with a pre-release suffix.
if: github.event_name != 'release' || !github.event.release.prerelease
runs-on: ubuntu-24.04
permissions:
contents: read
# configure-pages only reads the Pages configuration (base_url); deploy needs write.
pages: read
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.gridcraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
tag="$RELEASE_TAG"
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"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="gridcraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/gridcraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/gridcraft-$version-linux-$arch.flatpak"
done
# Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
# (re-mirrored and re-deployed on every release) does not grow without bound.
flatpak build-update-repo \
--prune --prune-depth=3 \
--title="GridCraft" \
--comment='Official GridCraft releases' \
--description='Official Flatpak repository for GridCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/gridcraft.flatpakrepo" <<META
[Flatpak Repo]
Title=GridCraft
Comment=Official GridCraft releases
Description=Official Flatpak repository for GridCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
flatpak install gridcraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+4 -5
View File
@@ -51,16 +51,15 @@ jobs:
GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases embed it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -88,7 +87,7 @@ jobs:
cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
fi
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: inputs.package
with:
name: release-freebsd
+33 -3
View File
@@ -1,5 +1,5 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Quick release-pipeline checks without a Rust build: workflow and packaging-script syntax,
# Linux desktop metadata, and the Windows desktop-shortcut installer lifecycle.
name: Packaging lint
on:
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -64,3 +64,33 @@ jobs:
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
desktop-shortcut:
name: Windows desktop shortcut smoke
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build fixture MSIs and test shortcut lifecycle
shell: pwsh
run: |
dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'Could not install WiX' }
$env:PATH = "$env:RUNNER_TEMP\wix;$env:PATH"
./packaging/windows/test-desktop-shortcut.ps1
- name: Capture native MSI dialog previews
shell: pwsh
run: |
pwsh -NoProfile -STA -File ./packaging/windows/preview-installer-dialogs.ps1 `
-Msi "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\gridcraft-90.0.0.msi" `
-OutputDir "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\dialogs"
if ($LASTEXITCODE -ne 0) { throw 'Native MSI dialog preview failed' }
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: desktop-shortcut-msi-evidence
path: |
${{ runner.temp }}/gridcraft-shortcut-smoke/*.log
${{ runner.temp }}/gridcraft-shortcut-smoke/dialogs/*
if-no-files-found: ignore
retention-days: 7
+95 -30
View File
@@ -45,7 +45,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -73,18 +73,17 @@ jobs:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -104,7 +103,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -132,18 +131,17 @@ jobs:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
@@ -167,7 +165,7 @@ jobs:
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -191,10 +189,9 @@ jobs:
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
@@ -208,8 +205,8 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -233,7 +230,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -256,8 +253,8 @@ jobs:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -271,7 +268,7 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@@ -287,18 +284,17 @@ jobs:
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
@@ -314,12 +310,81 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
# FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true,
# uploads the release-freebsd artifact (gridcraft-<v>-freebsd-x86_64.tar.gz). It needs no
# secrets, so the called workflow runs without the release environment (and in dry runs).
@@ -334,7 +399,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, web, freebsd]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd]
# Belt and braces for branch dry runs: only the release branch drafts a release (the
# environment's branch rule refuses this job and the signing jobs anywhere else, too).
if: github.ref == 'refs/heads/release'
@@ -348,7 +413,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
+5 -5
View File
@@ -28,11 +28,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@@ -43,7 +43,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: gridcraft-windows-arm64
path: dist/arm64/*
@@ -56,7 +56,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: gridcraft-windows-arm64
path: dist
+2 -2
View File
@@ -43,14 +43,14 @@ 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`.
- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` regenerates `docs/parity.md` from `crates/engine/src/catalog.rs`.
`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`.
## Contributor credits (About window)
+2
View File
@@ -18,6 +18,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/app-icon/gridcraft-1024.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from gridcraft.svg |
| `assets/app-icon/gridcraft-macos-512.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render |
| `assets/app-icon/gridcraft.icns` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon |
+2 -2
View File
@@ -43,11 +43,11 @@ 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`.
- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` regenerates `docs/parity.md` from `crates/engine/src/catalog.rs`.
`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`.
Generated
+125 -19
View File
@@ -215,6 +215,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1121,6 +1122,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1132,6 +1139,23 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1289,7 +1313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1392,10 +1416,12 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"arboard",
"eframe",
"egui",
"fmv-macos-events",
"gridcraft-engine",
"gridcraft-ui-egui",
"image",
@@ -1408,7 +1434,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1420,7 +1446,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1431,7 +1457,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1441,7 +1467,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"serde",
"serde_json",
@@ -1450,7 +1476,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1466,11 +1492,12 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "gridcraft-formula"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -1479,7 +1506,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -1487,7 +1514,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -1496,7 +1523,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1507,18 +1534,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.3.0"
version = "0.5.0"
[[package]]
name = "gridcraft-ui-egui"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"egui",
"egui_kittest",
@@ -1528,12 +1555,14 @@ dependencies = [
"log",
"serde",
"serde_json",
"sys-locale",
"toml",
"web-sys",
]
[[package]]
name = "gridcraft-web"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -1550,7 +1579,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1593,6 +1622,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1601,7 +1639,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1610,7 +1648,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2038,6 +2076,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2426,6 +2473,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2474,6 +2531,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3187,6 +3255,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3355,6 +3432,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -4222,6 +4310,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "x11-dl"
version = "2.21.0"
@@ -4287,7 +4393,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"gridcraft-engine",
"serde_json",
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.3.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -43,6 +43,8 @@ eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
sys-locale = "0.3"
[workspace.lints.rust]
unsafe_code = "forbid"
+67 -8
View File
@@ -84,6 +84,9 @@
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
PivotTables and print settings round-trip. CSV and TSV too.
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
structured table references, Excel's full number-format language, sort and AutoFilter,
@@ -105,6 +108,9 @@ cargo run --release -p gridcraft -- --sample sales --control 7979 # + JSON con
cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
```
Choose Español under View → Interface language for [Spanish formula names, decimal commas, and semicolon arguments](docs/formula-input.md).
Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax.
### Web
```sh
@@ -115,6 +121,42 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
browser's file picker (dropping files works too) and Save downloads the workbook.
### Ribbon access keys
Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
then type a sequence below one key at a time. Keytips appear on the supported Home
controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option
remains available for entering accented characters.
| Sequence after Alt/F10 | Action |
|---|---|
| `H A L` / `H A C` / `H A R` | Align left / center / right |
| `H V S` or legacy `E S` | Open Paste Special |
| `H O I` or legacy `O C A` | AutoFit selected columns |
In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles
Transpose. **Enter** applies the selected options; **Escape** cancels the dialog.
These letters also work with Alt held on Windows/Linux/BSD.
Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling,
or switching windows cancels navigation. Access keys do not start while editing text
or using another dialog. This is initial support for these Home and legacy paths;
other ribbon commands and full mouse-free navigation remain to be implemented.
Browsers or desktop environments may reserve Alt/F10 for their own menus.
### Logs
The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder
next to its settings: `~/.config/gridcraft/logs` on Linux and BSD (`$XDG_CONFIG_HOME/gridcraft/logs`
when set), `~/Library/Application Support/GridCraft/logs` on macOS and
`%APPDATA%\GridCraft\logs` on Windows. Each start moves the previous log to `gridcraft.1.log` and
that one to `gridcraft.2.log`, so the log of a run that crashed survives the next start; attach
them to a bug report. A log file stops growing at 16 MiB. By default GridCraft's own crates log
at `info` and everything else at `warn`; `RUST_LOG` replaces that with env_logger-style
directives, e.g. `RUST_LOG=debug` or `RUST_LOG=warn,gridcraft_engine=debug` (a trailing `*`
matches a prefix: `gridcraft*=debug`). Runs with `GRIDCRAFT_NO_PREFS` set (no saved settings)
log to standard error only.
## For agents: MCP, CLI and the control channel
```sh
@@ -141,7 +183,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|---|---|
| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) |
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) |
| L3 | `chart` (toolkit-free chart layout and rendering) |
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
@@ -153,12 +195,13 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
## Roadmap
GridCraft is pre-alpha and moving fast. It covers 89% of Excel's ribbon and menu commands
([`docs/parity.md`](docs/parity.md)) and about 93% of its worksheet functions; weighted by depth,
we estimate about 65% of what Excel power users rely on. We're roughly 80% of the way to a first
alpha. Next up: signed release builds, XLSX fidelity on real-world files, performance on very large
sheets, then slicers, Solver, chart trendlines and a true Page Layout view. The plan and estimates
are in [ROADMAP.md](ROADMAP.md).
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 501 of Excel's 523 worksheet functions
([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel 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).
## Downloads
@@ -172,7 +215,23 @@ are in [ROADMAP.md](ROADMAP.md).
| arm64 (Snapdragon and other ARM PCs) | `gridcraft-<ver>-windows-arm64.msi` | `gridcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `gridcraft-<ver>-windows-x86.msi` | `gridcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
Installers and executables are code-signed. The MSI installer offers an optional desktop shortcut
(unchecked on a new installation).
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND = "vulkan"
& .\gridcraft.exe
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
### macOS
+137 -124
View File
@@ -1,136 +1,149 @@
# GridCraft roadmap
GridCraft aims at full Microsoft Excel parity — and to be better: faster, open (XLSX is the
native format), fully scriptable by agents (MCP + control channel + CLI), and available on the
web.
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
## Status (2026-10-07)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365)
### Where we are
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.
This page is the summary. The detail is in:
| Measure | Value | Notes |
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative assessment, method and weights
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list
- [`docs/roadmap.md`](docs/roadmap.md): milestones, current focus, what's next
- [`docs/parity-checklist.md`](docs/parity-checklist.md): generated by `cargo xtask parity` (ribbon commands)
- Area checklists: [functions](docs/function-parity.md) · [charts](docs/chart-parity.md) ·
[file formats](docs/file-format-parity.md) · [UI](docs/ui-parity.md) ·
[hardware](docs/hardware-parity.md) · [localization](docs/localization-parity.md)
- [`docs/architecture.md`](docs/architecture.md): how it is built
## Headline numbers
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu command catalog | **89%** (258 / 290) | `cargo xtask parity` → [`docs/parity.md`](docs/parity.md). Home, Draw, Page Layout, Formulas, Automate, Chart Design and PivotTable Design are at 100%. |
| Worksheet functions | **~93%** of Excel's ~545 | Missing: ODDF*/ODDL*, BAHTTEXT, IMAGE, TRANSLATE, COPILOT/PY, STOCKHISTORY, RTD |
| Feature depth (weighted) | **~65%** | The catalog counts a command once it exists; many are shallower than Excel's (see "What's left") |
| Tests | 332 passing | engine, formula, functions, calc, XLSX (incl. PivotTable round-trip), PDF, MCP, CLI, kittest UI |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
| Remaining to **beta** | **~160–240 Opus 5.5 agent-hours** | estimated; itemized in [roadmap.md](docs/roadmap.md#beta-gates) |
| Remaining to **full parity** | **~700–1,100 Opus 5.5 agent-hours** | estimated; sum of the dimension rows below |
The catalog number is generous: it says "this button does something real", not "it does
everything Excel's does". The weighted depth figure is our honest estimate of how much of what an
Excel power user relies on works as well as in Excel.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile |
| Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) |
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` and encrypted import 28–42 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
### How close to an alpha?
Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and
calibration](docs/target-app-parity.md#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy
work; single features measured from PRs #66, #100, #101). Parallelism: the full and mainstream
work splits ~65–70% across 4–6 agents by area; the essentials work is mostly small fixes plus one
long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with three agents).
**About 80% of the way to an alpha. Estimate: ~35 wall-clock hours of Claude Opus 5.5 agent
work** (2–3 days with 2–3 parallel agents), plus a few minutes of human time to set up release
secrets.
Why alpha: all six core workflows in the [alpha gate](docs/roadmap.md#alpha-gate) pass end to end on macOS with save and reopen, and the core workflow works end to end (open an XLSX, enter formulas and data, format, sort
and filter, chart, pivot, print to PDF, save XLSX), but users still hit blocking gaps on real
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Arabic and Devanagari text
isn't shaped (Chinese text still reported in #142), charts lose their type and options on round trip, and
nobody has yet confirmed that Excel opens our output without a repair prompt across a real
corpus. Beta needs those closed (see [beta gates](docs/roadmap.md#beta-gates)).
Alpha means: someone can download a signed build, open their real workbooks, edit and save them
without losing data, and not hit a crash in a normal session. The gates:
The 2026-10-07 numbers (catalog 89%, "feature depth ~65%", "~215 h to full parity") were
re-measured. Catalog and function counts held or rose; the depth and hours moved because of new
evidence: 82 issues filed by users and contributors in the repo's first five days, and scope the
old estimate left out (localization, legacy `.xls`, Power Query and the data model,
collaboration, mobile, AI).
| Alpha gate | State | Est. hours |
## By dimension
| Dimension | Ready for real work | Hours to parity | Doc |
|---|---|---|---|
| Features (13 areas, table below) | ~59% (breadth ~75%) | 300–485 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| UI/UX fidelity | ~55% | 50–80 | [ui-parity.md](docs/ui-parity.md) |
| File formats | ~50% | 80–120 | [file-format-parity.md](docs/file-format-parity.md) |
| Performance | ~35% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#performance) |
| Stability | ~60% | 15–25 | [gaps.md](docs/gaps.md) |
| Localization | ~10% (ribbon in 5 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
| Hardware (multi-core calc, GPU, pen, multi-window) | ~40% | 25–40 | [hardware-parity.md](docs/hardware-parity.md) |
| Platforms | ~80% (no iOS/Android; ahead on Linux, BSD, RISC-V) | 30–60 | [target-app-parity.md](docs/target-app-parity.md#platforms) |
| Ecosystem / add-ins | ~10% (VBA out of scope; own scripts, MCP, CLI) | 25–40 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) |
| AI features | ~15% (agent control strong; no in-app assistant) | 30–50 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) |
| **Total** | **~50%** | **~700–1,100** | |
## Features
| Area | Breadth | Ready | Hours | Doc |
|---|---|---|---|---|
| Formula language and calculation | ~92% | ~72% | 20–30 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Worksheet functions | 95.8% measured | ~82% | 18–30 | [function-parity.md](docs/function-parity.md) |
| Editing, navigation, clipboard, fill | ~90% | ~70% | 15–25 | [ui-parity.md](docs/ui-parity.md) |
| Cell formatting, styles, conditional formatting | ~95% | ~80% | 8–14 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Sort, filter, tables, validation, outline | ~82% | ~70% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| PivotTables | ~65% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Charts and sparklines | ~50% | ~30% | 40–60 | [chart-parity.md](docs/chart-parity.md) |
| Page layout, print, PDF | ~80% | ~55% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| What-if and analysis (Goal Seek, Solver, ToolPak, Forecast) | ~50% | ~40% | 22–35 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
## Languages
The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese and
Russian (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82), 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. German
is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Status | UI strings |
|---|---|---|---|
| English | en | full (source language) | 100% |
| Simplified Chinese | zh-Hans | menus only | ~12% |
| Spanish | es | none | 0% |
| Hindi | hi | none | 0% |
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
| French | fr | none | 0% |
| Portuguese | pt-BR | menus only | ~12% |
| Indonesian | id | none | 0% |
| Japanese | ja | menus only | ~12% |
| German | de | none (PR #163 open) | 0% |
| Korean | ko | menus only | ~12% |
| Vietnamese | vi | none | 0% |
Other shipped languages: 1 (Russian, menus only, ~12%).
## Upcoming
Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
[docs/roadmap.md](docs/roadmap.md).
1. **Land the open PR queue** (50 open PRs on 2026-10-10, many fixing listed gaps: recalc and spill
correctness, chart kind round-trip, CJK font fallback, macOS Finder open, German and Chinese
UI). Review and merge: 8–15 h.
2. **XLSX fidelity on a real-world corpus**, with Excel opening our output without repair: 25–35 h.
3. **Large workbooks and recalc speed**: 100 MB XLSX, 1M rows, multi-threaded recalc: 40–60 h.
4. **Charts depth**: kinds and options round-trip, axes, trendlines, error bars: 25–35 h.
5. **Text in every script** (CJK, Thai, Arabic, Devanagari) in cells and PDF: 12–20 h.
6. **Legacy `.xls` import and password-protected workbooks**: 20–30 h.
7. **Localization**: extend the catalog past the ribbon, then the twelve languages: 100–165 h.
## Progress log
| Date | What landed |
|---|---|
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
| 2026-10-06 | M0–M13 first pass: engine, 478 functions, XLSX/CSV, egui UI, charts, PivotTables with XLSX round-trip, PDF and printing, what-if, spelling, Draw, task panes, MCP, CLI, web build, release packaging |
| 2026-10-05 | Repository created |
## Revision history
| Date | Change | Summary |
|---|---|---|
| Signed release builds on every platform | Workflows and packaging ready; **blocked on the `release` environment and secrets, which need a human** (see [`docs/releasing.md`](docs/releasing.md)). Then a test build per platform | 4 |
| XLSX fidelity on real files | Round-trip tests pass for our own files. Needs a corpus run on real-world workbooks, and confirming Excel opens our output (including PivotTables) with no repair prompt | 10 |
| Performance | Virtualised grid and O(log n) geometry are in. Still need: 100k-cell edit recalc under 100 ms (currently ~0.4 s), faster write-back, 1M-row open | 8 |
| Never-crash soak | Every-command fuzz test exists. Needs a longer randomised UI soak and an autosave/recovery check | 4 |
| Papercuts | Dark-mode row/column headers, a true Page Layout (paper) view, focus edge cases, error messages | 6 |
| Docs and first-run | README, CLI/MCP docs exist; needs a "getting started" page and release notes | 3 |
| **Total** | | **≈ 35** |
### How far to 100% parity?
**Estimate: ~180 wall-clock hours of Claude Opus 5.5 agent work beyond the alpha** (about 1–1.5
weeks running continuously with 3–4 parallel agents), so **≈ 215 hours in total from today**.
Breakdown under "What's left" below.
Scope decisions that affect the count: VBA is out of scope (GridCraft has its own recorded and
editable scripts instead), and features that need Microsoft cloud services (Copilot, live Stocks/
Geography data types, co-authoring through OneDrive, Smart Lookup, Translate) will be replaced
by open equivalents or left out, not cloned.
### What's left
| Area | What's missing | Est. hours |
|---|---|---|
| PivotTables | Slicers and timelines, calculated fields/items, PivotCharts, value/label filters, number grouping | 24 |
| Charts | Trendlines, error bars, full axis options (log scale, units, crossing), element-level Format pane, chart sheets, Recommended Charts dialog, maps | 26 |
| Data | Solver, Analysis ToolPak, Forecast Sheet, Get & Transform beyond CSV/JSON/HTML (Power Query editor lite), relationships / data model | 34 |
| Page layout & print | True Page Layout view, header/footer editor on the page, print preview polish | 12 |
| Insert & Review | Equations, WordArt, SmartArt-lite, signature line, comment navigation, Show Changes, Allow Edit Ranges, notes ↔ comments conversion | 20 |
| XLSX depth | Remaining OOXML parts (form controls, slicer caches, external links, OLE stubs), round-trip of everything we don't model | 16 |
| Functions | The remaining ~35 functions, exact Excel edge-case behaviour across the long tail | 10 |
| Performance | Parallel recalc, 1M-row workbooks everywhere (sort, filter, fill), memory | 14 |
| Fidelity | Side-by-side pixel tuning against Excel, keyboard shortcut completeness, accessibility, localisation | 24 |
| **Total** | | **≈ 180** |
**Working today**
- Engine: A1/R1C1 formula parser with reference adjustment, dependency-graph recalculation with
dynamic arrays (spill + `#SPILL!`), cycle detection, LET/LAMBDA/MAP/REDUCE/SCAN/BYROW/BYCOL/
MAKEARRAY, structured table references, defined names, INDIRECT/OFFSET/INDEX references,
478 worksheet functions (math, statistics and distributions, text and regex, lookup incl.
XLOOKUP/FILTER/SORT/UNIQUE/VSTACK, dates, financial incl. bonds, engineering incl. complex
numbers and Bessel, database, information).
- Number formats: Excel's full format-code language (sections, conditions, colours, dates and
elapsed time, fractions, scientific, accounting fill), General narrowing to column width.
- Files: XLSX read/write (styles, themes, merges, CF, validation, tables, comments, hyperlinks,
charts, pictures, sparklines, print settings, defined names, shared/array/dynamic formulas),
CSV/TSV with encoding and delimiter detection, HTML export, JSON.
- Editing: cell entry with type detection and auto-formats, in-cell and formula-bar editing,
point mode (click/drag/arrow references), reference colouring, F4 anchor cycling, function
autocomplete and argument hints, copy/cut/paste and Paste Special (values, formats, transpose,
operations, link, skip blanks), AutoFill series (numbers, dates, months, weekdays, custom lists,
text+number), Fill Series, Flash Fill, find/replace with wildcards, Go To / Go To Special,
undo/redo with history, format painter.
- Formatting: fonts, fills, all border styles, alignment (wrap, indent, rotation, merge), number
formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates,
duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic
row heights, autofit, hide/unhide, freeze panes.
- Data: sort (multi-level, by colour, custom lists), AutoFilter (values, custom, top 10,
average, colour), tables with totals rows, remove duplicates, text to columns, data validation
with error alerts, grouping/outline, subtotals.
- PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts,
date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip.
- Page layout & print: Page Break Preview, print settings, PDF export and printing.
- What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus.
- Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded
replies, resolve), Watch Window, Selection, Format Chart.
- Charts: column, bar, line, area, pie, doughnut, scatter, bubble, radar, histogram, waterfall,
funnel, treemap, sunburst, box & whisker, stock and combo charts; sparklines.
- UI (egui): Excel-style title bar with Quick Access Toolbar and AutoSave, the full ribbon with
galleries (cell styles, table styles, colour palettes, conditional formatting menus),
contextual Table/Chart Design tabs, formula bar with Name Box, virtualised grid (smooth
scrolling, zoom, frozen panes, text overflow), sheet tabs (rename, reorder, colour, hide),
status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Formula Builder, Find and
Replace, Sort, Name Manager, Data Validation, Paste Special…), command palette, dark mode.
- Agents: every action is an engine command; JSON control channel in the desktop app (pointer,
keyboard, dialogs, screenshots); MCP server (headless or bridged); CLI (`info`, `convert`,
`eval`, `cat`, `run`, `commands`, `functions`, `mcp`, `send`).
- Platforms: macOS, Windows, Linux, FreeBSD; web (WASM via trunk). Release workflows for
signed/notarized macOS universal DMG, signed Windows x64/x86 MSI + zip, Linux AppImage/deb/rpm/
tar.gz + Flatpak manifest, FreeBSD tarball and a web zip.
## Milestones
| # | Milestone | Status |
|---|---|---|
| M0 | Foundation: core, number formats, formula language, functions, model, calc, XLSX/CSV, engine | ✅ done |
| M1 | Excel look: chrome, ribbon, formula bar, grid, tabs, status bar, control channel | ✅ done (polish continues) |
| M2 | Formatting completeness | ✅ done |
| M3 | Editing power (AutoComplete, spelling, drag-move/copy) | ✅ done |
| M4 | Data (advanced filter, consolidate, what-if) | 🟡 most; Solver, ToolPak and Power Query remain |
| M5 | Formulas tab (trace arrows, watch window, evaluate, error checking) | ✅ done |
| M6 | Conditional formatting manager parity | ✅ done |
| M7 | Charts parity | 🟡 partial; Format pane in, trendlines/axes/error bars remain |
| M8 | Insert & Review | 🟡 partial; ink, icons, comments pane in |
| M9 | Page layout & print | 🟡 most; true Page Layout view remains |
| M10 | PivotTables, Solver, Analysis ToolPak | 🟡 PivotTables in; slicers, Solver, ToolPak remain |
| M11 | Automation (record actions, scripts) | ✅ done |
| M12 | Performance, accessibility, i18n | 🟡 partial |
| M13 | Release & polish | 🟡 tooling ready; needs release secrets |
## How to measure
- `cargo xtask parity` — catalog coverage (feature names from Excel's ribbon and menus).
- `cargo test --workspace` — engine, formula, function, XLSX and UI behaviour.
- `cargo run --release -p gridcraft-ui-egui --example snapshot -- --sample sales out.png` — look at the UI offscreen.
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` |
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
+9 -2
View File
@@ -145,8 +145,15 @@ impl Args {
// ---------------------------------------------------------------- helpers
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
let r = s.execute(id, params).map_err(|e| e.to_string());
s.take_ui_requests();
let r = s.execute_headless(id, params).map_err(|e| e.to_string());
if id == "file.open"
&& let Ok(value) = &r
&& let Some(warnings) = value.get("warnings").and_then(Value::as_array)
{
for warning in warnings.iter().filter_map(Value::as_str) {
eprintln!("Warning: {warning}");
}
}
r
}
@@ -0,0 +1,26 @@
use std::process::Command;
#[test]
fn text_to_columns_preserves_doubled_quotes() {
let dir = std::env::temp_dir().join(format!("gridcraft-t2c-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("make temp directory");
let script = dir.join("steps.jsonl");
std::fs::write(
&script,
concat!(
"{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"\\\"a\\\"\\\"b\\\",c\"}}\n",
"{\"command\":\"data.textToColumns\",\"params\":{\"range\":\"A1\",\"delimiters\":[\",\"]}}\n",
),
)
.expect("write commands");
let output = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
.args(["run", "--script"])
.arg(&script)
.args(["--print", "A1:B1", "--csv", "--quiet"])
.output()
.expect("run Gridcraft CLI");
std::fs::remove_file(script).expect("remove script");
std::fs::remove_dir(dir).expect("remove temp directory");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "\"a\"\"b\",c");
}
+1 -1
View File
@@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
+61 -4
View File
@@ -37,8 +37,17 @@ pub fn start() {
} else if q.contains("sample") {
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
}
let app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
SheetApp::setup_context(&cc.egui_ctx, false);
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
let browser = gridcraft_ui_egui::i18n::Language::system();
if !matches!(
browser,
gridcraft_ui_egui::i18n::Language::Zh | gridcraft_ui_egui::i18n::Language::Ja | gridcraft_ui_egui::i18n::Language::Ko
) {
app.ui.language = browser;
}
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
Ok(Box::new(WebShell { app, inbox }))
}),
)
@@ -92,13 +101,17 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let picture_ctx = ctx.clone();
Services {
copy_html: Some(Box::new(copy_html)),
open_async: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) =
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]).pick_file().await
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.pick_file()
.await
else {
return;
};
@@ -107,6 +120,17 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
ctx.request_repaint();
});
})),
pick_picture_async: Some(Box::new(move |inbox| {
let ctx = picture_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"]).pick_file().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
download: Some(Box::new(|name: &str, bytes: &[u8]| {
if let Err(e) = download(name, bytes) {
log::error!("download of {name} failed: {e}");
@@ -122,6 +146,39 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
}
}
/// Start inside the copy gesture; deferring `write` can lose browser user activation.
fn copy_html(html: &str, text: &str) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let clipboard = window.navigator().clipboard();
if clipboard.is_undefined() || clipboard.is_null() {
return Err("browser clipboard is unavailable (a secure context is required)".into());
}
if !js_sys::Reflect::get(&clipboard, &"write".into()).map_err(js)?.is_function() {
return Err("browser does not support rich clipboard writes".into());
}
let data = js_sys::Object::new();
for (mime, value) in [("text/html", html), ("text/plain", text)] {
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime);
let parts = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(value));
let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts).map_err(js)?;
js_sys::Reflect::set(&data, &mime.into(), &blob).map_err(js)?;
}
let item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&data).map_err(js)?;
let promise = clipboard.write(&js_sys::Array::of1(&item));
let text = text.to_owned();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await {
log::warn!("Rich clipboard write failed; trying plain text: {e:?}");
if let Err(e) = wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
log::error!("Plain-text clipboard fallback also failed: {e:?}");
}
}
});
Ok(())
}
/// Triggers a browser download of `bytes`.
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
+9
View File
@@ -25,6 +25,15 @@ winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
# Finder double-click, Open With, Dock drops and `open -a GridCraft file.xlsx` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle (#69). This audited crate (also used
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
+49
View File
@@ -0,0 +1,49 @@
//! macOS open-documents and quit Apple events (#69).
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a GridCraft book.xlsx` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so AppKit answered "GridCraft cannot open files in the Excel Workbook
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
//! workspace forbids. The audited `fmv-macos-events` crate (also used by PdfCraft and PhotoCraft)
//! wraps exactly that, an `NSAppleEventManager` handler registered before Finder's launch event
//! that leaves winit's delegate alone, behind a safe main-thread API.
use fmv_macos_events::{Event, Inbox, Registration};
use gridcraft_ui_egui::SheetApp;
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
app.open_path(p);
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlR
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
})?;
eprintln!("GridCraft control channel listening on 127.0.0.1:{port}");
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
+47
View File
@@ -0,0 +1,47 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
}
}
}
+580
View File
@@ -0,0 +1,580 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a sample that failed to
//! build, a graphics adapter warning, a panic a command's guard recovered from) vanished. A launch
//! from a desktop menu has no terminal, so the file is what a bug report can attach:
//! `<settings dir>/logs/gridcraft.log`, next to `ui.json` and `prefs.json` (`config_dir` in
//! `main.rs`). Each start moves the previous log to `gridcraft.1.log` (and that one to `.2`), so
//! the log of a run that crashed survives the next launch. Runs without preferences
//! (`GRIDCRAFT_NO_PREFS`) log to standard error only.
//!
//! Levels: `info` for GridCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,gridcraft_engine=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`gridcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
//!
//! This is the desktop app's logger only; `gridcraft-cli` and `gridcraft-web` set up their own.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "gridcraft.log";
/// How many previous logs are kept (`gridcraft.1.log` … `gridcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,gridcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("gridcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
// No file is coming ([`Sink::no_file`]): standard error had the line.
} else if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
self.written = 0;
self.capped = false;
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// There will be no log file: forget the lines kept for it and keep no more.
pub fn no_file(&mut self) {
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
/// standard error stays the only sink.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
// A thread that panicked while logging leaves the sink usable: take it back.
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
match rotate(dir) {
Ok((path, file)) => {
sink.attach(file);
Ok(path)
}
Err(e) => {
sink.no_file();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file for this run (runs without preferences): log to standard error only.
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
use log::Log;
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("gridcraft"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace);
assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug);
assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"gridcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,gridcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "gridcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"),
"2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2");
assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "gridcraft", "before the settings directory");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Error, "gridcraft", "internal error: boom");
record(&logger, log::Level::Debug, "gridcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("gridcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}"));
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
record(&logger, log::Level::Error, "x", "still fine");
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
record(&logger, log::Level::Warn, "gridcraft", "after the poison");
assert!(read(&path).contains("gridcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+97 -9
View File
@@ -8,7 +8,12 @@
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_os = "macos")]
mod apple_events;
mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -16,7 +21,7 @@ use gridcraft_engine::Session;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
@@ -28,6 +33,7 @@ impl eframe::App for App {
if let Some(m) = &self.1 {
m.poll(&mut self.0, ctx);
}
apple_events::poll(&self.2, &mut self.0, ctx);
}
self.0.logic(ctx);
}
@@ -61,15 +67,31 @@ fn config_dir() -> Option<std::path::PathBuf> {
}
}
/// Runs without preferences (`GRIDCRAFT_NO_PREFS`, agents' test runs) neither read nor write
/// them, and keep no log file.
fn prefs_enabled() -> bool {
std::env::var_os("GRIDCRAFT_NO_PREFS").is_none()
}
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
fn log_dir() -> Option<std::path::PathBuf> {
Some(config_dir()?.join("logs"))
}
fn load_prefs(app: &mut SheetApp) {
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
// First run (or a ui.json without a usable language): follow the desktop's language.
app.ui.language = gridcraft_ui_egui::i18n::Language::system();
let Some(dir) = config_dir() else { return };
if let Ok(b) = std::fs::read(dir.join("ui.json"))
&& let Ok(ui) = serde_json::from_slice(&b)
&& let Ok(ui) = serde_json::from_slice::<gridcraft_ui_egui::UiState>(&b)
{
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
let language = saved.unwrap_or(app.ui.language);
app.ui = ui;
app.ui.language = language;
}
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
&& let Ok(p) = serde_json::from_slice(&b)
@@ -79,7 +101,7 @@ fn load_prefs(app: &mut SheetApp) {
}
fn save_prefs(app: &SheetApp) {
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
let Some(dir) = config_dir() else { return };
@@ -93,15 +115,31 @@ fn save_prefs(app: &SheetApp) {
}
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let mut clipboard: Option<arboard::Clipboard> = None;
Services {
copy_html: Some(Box::new(move |html, text| {
if clipboard.is_none() {
clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
.add_filter("CSV", &["csv"])
.pick_file()
.and_then(|p| p.to_str().map(str::to_string))
})),
pick_picture: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"])
.pick_file()
.and_then(|p| p.to_str().map(str::to_string))
})),
pick_save: Some(Box::new(|suggested: &str| {
let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
rfd::FileDialog::new()
@@ -133,6 +171,26 @@ fn icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result<()> {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
// Commands catch their panics (`Session::execute`), but the default hook only prints to
// standard error; this one sends every panic, caught or not, to the log file too, with a
// backtrace when RUST_BACKTRACE is set.
std::panic::set_hook(Box::new(|info| {
let trace = std::backtrace::Backtrace::capture();
let report = if trace.status() == std::backtrace::BacktraceStatus::Captured {
format!("internal error: {info}\n{trace}")
} else {
format!("internal error: {info}")
};
// Standard error and the log file; standard error alone when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{report}");
} else {
// `eprintln!` panics on a broken stderr pipe, and a panic inside the panic hook aborts.
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("gridcraft: {report}\n"));
}
}));
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
@@ -157,21 +215,36 @@ fn main() -> eframe::Result<()> {
}
i += 1;
}
// The log file lives in the settings folder, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
// don't rotate away the user's own logs.
if let Some(logger) = logger {
match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => match logger.attach_dir(&dir) {
Ok(path) => log::info!("GridCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
},
None => logger.no_file(),
}
}
let mut session = Session::new();
if let Some(s) = &sample
&& let Err(e) = session.execute("file.new", json!({"sample": s}))
{
eprintln!("sample: {e}");
log::warn!("sample {s}: {e}");
}
for f in &files {
if let Err(e) = session.execute("file.open", json!({"path": f})) {
eprintln!("{f}: {e}");
log::warn!("{f}: {e}");
}
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.after_engine(); // Show warnings from files opened on the command line.
let control_port = control_port;
load_prefs(&mut app);
let mut viewport = egui::ViewportBuilder::default()
@@ -187,21 +260,36 @@ fn main() -> eframe::Result<()> {
viewport = viewport.with_icon(i);
}
let options = eframe::NativeOptions { viewport, ..Default::default() };
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
#[cfg(target_os = "windows")]
let options = {
let mut options = options;
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
options
};
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
"GridCraft",
options,
Box::new(move |cc| {
SheetApp::setup_context(&cc.egui_ctx, app.ui.dark);
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
if let Some(port) = control_port {
match control_server::start(port, cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => eprintln!("control channel on port {port}: {e}"),
Err(e) => log::error!("control channel on port {port}: {e}"),
}
}
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
None,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
+78 -8
View File
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, channel};
use gridcraft_ui_egui::SheetApp;
use muda::accelerator::Accelerator;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::{Value, json};
type Entry = (&'static str, &'static str, Option<&'static str>);
@@ -78,7 +78,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Freeze Top Row", "view.freezeTopRow", None),
("Unfreeze Panes", "view.unfreezePanes", None),
("-", "", None),
("Dark Mode", "view.darkMode", None),
("Display Theme", "view.theme", None),
("Collapse Ribbon", "view.collapseRibbon", Some("CMD+ALT+R")),
],
),
@@ -181,6 +181,18 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
pub struct NativeMenu {
_menu: Menu,
events: Receiver<MenuEvent>,
theme_items: Vec<(&'static str, CheckMenuItem)>,
}
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
let mode = match id {
"theme:system" => "system",
"theme:light" => "light",
"theme:dark" => "dark",
_ => return false,
};
app.run_or_alert("view.theme", json!({"mode": mode}));
true
}
impl NativeMenu {
@@ -200,6 +212,7 @@ impl NativeMenu {
&PredefinedMenuItem::quit(None),
]);
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
for (title, entries) in tree() {
let sub = Submenu::new(title, true);
for (label, id, acc) in entries {
@@ -207,6 +220,16 @@ impl NativeMenu {
let _ = sub.append(&PredefinedMenuItem::separator());
continue;
}
if id == "view.theme" {
let themes = Submenu::new(label, true);
for (mode, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
let item = CheckMenuItem::with_id(format!("theme:{mode}"), label, true, false, None);
let _ = themes.append(&item);
theme_items.push((mode, item));
}
let _ = sub.append(&themes);
continue;
}
let accel = acc.and_then(|a| Accelerator::from_str(a).ok());
let item = MenuItem::with_id(id, label, true, accel);
let _ = sub.append(&item);
@@ -220,12 +243,40 @@ impl NativeMenu {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeMenu { _menu: menu, events: rx }
NativeMenu { _menu: menu, events: rx, theme_items }
}
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
while let Ok(ev) = self.events.try_recv() {
let id = ev.id.as_ref().to_string();
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
if app.text_box_editor.is_some() {
let clipboard = match id.as_str() {
"edit.copy" => Some(egui::ViewportCommand::RequestCopy),
"edit.cut" => Some(egui::ViewportCommand::RequestCut),
"edit.paste" => Some(egui::ViewportCommand::RequestPaste),
_ => None,
};
if let Some(command) = clipboard {
ctx.send_viewport_cmd(command);
continue;
}
if id == "edit.undo" || id == "edit.redo" {
ctx.input_mut(|i| {
i.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: id == "edit.redo", ..Default::default() },
});
});
continue;
}
}
if select_theme(app, &id) {
continue;
}
if let Some(url) = id.strip_prefix("url:") {
if let Some(open) = &app.services.open_url {
open(url);
@@ -243,14 +294,33 @@ impl NativeMenu {
let name = if name == "note" { "comment" } else { name };
app.open_dialog(name, params);
} else if id == "edit.copy" || id == "edit.cut" {
if let Ok(r) = app.run(&id, json!({}))
&& let Some(t) = r.get("text").and_then(Value::as_str)
{
ctx.copy_text(t.to_string());
}
app.copy_to_clipboard(ctx, &id);
} else {
app.run_or_alert(&id, json!({}));
}
}
for (mode, item) in &self.theme_items {
let checked = app.ui.theme_mode() == *mode;
if item.is_checked() != checked {
item.set_checked(checked);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
for mode in ["dark", "system", "light"] {
assert!(select_theme(&mut app, &format!("theme:{mode}")));
assert_eq!(app.ui.theme_mode(), mode);
assert!(app.message.is_none());
}
assert!(!select_theme(&mut app, "view.formulaBar"));
assert_eq!(app.ui.theme_mode(), "light");
}
}
+8
View File
@@ -2,3 +2,11 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
# The web app (wasm32-unknown-unknown) has no clock: these trap (`RuntimeError: unreachable`)
# instead of returning. Read the clock only off wasm (`cfg(not(target_arch = "wasm32"))` or
# `cfg!`) and allow the lint at that call.
disallowed-methods = [
{ path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" },
{ path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" },
]
+215 -11
View File
@@ -2,23 +2,218 @@
"schema": 1,
"repo": "storytold/gridcraft",
"branch": "main",
"head": "a60bd2f40edae13197965ded9433dcf276caca45",
"head_date": "2026-10-07T18:45:37Z",
"commits": 16,
"head": "90b3c41eec691797129303603d4073269aebab73",
"head_date": "2026-10-10T19:59:42Z",
"commits": 148,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 16,
"lines_added": 78054,
"lines_deleted": 3954,
"prs": 5,
"commits": 46,
"lines_added": 81717,
"lines_deleted": 4837,
"binary_added": 35,
"binary_deleted": 12,
"first_commit": "2026-10-06T02:09:42Z",
"last_commit": "2026-10-07T18:45:37Z"
"last_commit": "2026-10-10T19:59:42Z"
},
{
"login": "switch87",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 646,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T10:06:39Z",
"last_commit": "2026-10-08T11:01:16Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 34,
"lines_deleted": 34,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:30Z",
"last_commit": "2026-10-08T14:02:29Z"
},
{
"login": "justinjohn0306",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 7,
"lines_added": 1408,
"lines_deleted": 116,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:28:53Z",
"last_commit": "2026-10-09T18:39:12Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 244,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 244,
"lines_deleted": 24,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T01:40:12Z",
"last_commit": "2026-10-09T20:46:29Z"
},
{
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 14,
"commits": 14,
"lines_added": 586,
"lines_deleted": 52,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T08:45:50Z",
"last_commit": "2026-10-10T17:06:52Z"
},
{
"login": "harrisonoconnorhover",
"people_entry": false,
"github_name": null,
"prs": 20,
"commits": 22,
"lines_added": 9266,
"lines_deleted": 371,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T09:28:48Z",
"last_commit": "2026-10-10T17:48:20Z"
},
{
"login": "LloydNicholson",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 2,
"lines_added": 130,
"lines_deleted": 25,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T15:13:40Z",
"last_commit": "2026-10-09T15:23:15Z"
},
{
"login": "lloyd-nich",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 5,
"lines_added": 854,
"lines_deleted": 88,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T18:36:08Z",
"last_commit": "2026-10-10T12:17:14Z"
},
{
"login": "dylanmtaylor",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 281,
"lines_deleted": 45,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T21:14:43Z",
"last_commit": "2026-10-10T03:14:29Z"
},
{
"login": "edurbs",
"people_entry": true,
"github_name": "Eduardo Soares",
"prs": 1,
"commits": 1,
"lines_added": 311,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T21:18:57Z",
"last_commit": "2026-10-09T21:18:57Z"
},
{
"login": "nihilist-narwhal",
"people_entry": true,
"github_name": null,
"prs": 19,
"commits": 35,
"lines_added": 2261,
"lines_deleted": 145,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T22:14:59Z",
"last_commit": "2026-10-10T10:40:00Z"
},
{
"login": "neko233-com",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1004,
"lines_deleted": 81,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T02:20:59Z",
"last_commit": "2026-10-10T02:20:59Z"
},
{
"login": "agammann",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 34,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T05:47:38Z",
"last_commit": "2026-10-10T05:47:38Z"
},
{
"login": "itrycodinganditsbad",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 68,
"lines_deleted": 7,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T11:25:38Z",
"last_commit": "2026-10-10T11:28:07Z"
}
],
"models": [
@@ -27,9 +222,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 15,
"lines_added": 78047,
"lines_deleted": 3954
"commits": 58,
"lines_added": 83111,
"lines_deleted": 4992
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 3,
"lines_added": 173,
"lines_deleted": 24
}
]
}
+7
View File
@@ -21,3 +21,10 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.nihilist-narwhal]
display_name = "nihilist-narwhal"
sync_github_name = false
[people.edurbs]
sync_github_name = true
+115 -24
View File
@@ -13,6 +13,10 @@ use gridcraft_model::Workbook;
/// Largest array a reference may expand to.
pub const MAX_CELLS: u64 = 16_000_000;
/// References up to this many cells are read at their full size; larger ones (`A:A`, ranges
/// reserved for data to come such as `$A$2:$A$50000`) stop at the sheet's used range, so that
/// formulas over them stay fast.
const FULL_SIZE_CELLS: u64 = 16_384;
const MAX_DEPTH: usize = 400;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -54,10 +58,19 @@ pub trait Host {
fn random(&mut self) -> f64;
/// Called when a volatile or dynamic-reference function is used.
fn note_volatile(&mut self) {}
/// The spill range of the dynamic array anchored at `anchor` (`None`: it doesn't spill).
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
self.workbook().sheet(sheet)?.spill_ranges.get(&anchor).copied()
}
/// Values of a block, row-major. Hosts can override with a sparse fast path.
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
range.iter().map(|c| self.cell_value(sheet, c)).collect()
}
/// The sheet's used range (`Sheet::used_range`, which walks every stored row). Hosts that
/// keep the workbook unchanged while evaluating can remember it.
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
self.workbook().sheet(sheet).and_then(|s| s.used_range())
}
}
pub struct Evaluator<'h> {
@@ -129,7 +142,10 @@ impl<'h> Evaluator<'h> {
if a.range.is_single() {
return self.host.cell_value(a.sheet, a.range.start);
}
let range = self.trim(a);
// A reference keeps its size, so the array has its shape and its blanks count
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
let range = if cells > FULL_SIZE_CELLS { self.trim(a) } else { a.range };
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
@@ -140,9 +156,11 @@ impl<'h> Evaluator<'h> {
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
/// positions stay right).
pub fn trim(&self, a: &Area) -> RangeRef {
let Some(sheet) = self.host.workbook().sheet(a.sheet) else { return a.range };
let used = sheet.used_range();
pub fn trim(&mut self, a: &Area) -> RangeRef {
if self.host.workbook().sheet(a.sheet).is_none() {
return a.range;
}
let used = self.host.used_range(a.sheet);
let (mut r1, mut c1) = (a.range.end.row, a.range.end.col);
match used {
Some(u) => {
@@ -322,6 +340,19 @@ impl<'h> Evaluator<'h> {
}
fn unary(&mut self, op: UnOp, x: &Expr) -> Ev {
if op == UnOp::Spill {
return match self.eval(x) {
Ev::R(areas) => match areas.as_slice() {
[a] if a.range.is_single() => match self.host.spill_range(a.sheet, a.range.start) {
Some(range) => Ev::R(vec![Area { sheet: a.sheet, range }]),
None => err(CellError::Ref),
},
_ => err(CellError::Ref),
},
Ev::V(Value::Error(e)) => err(e),
_ => err(CellError::Ref),
};
}
if op == UnOp::At {
let ev = self.eval(x);
return match ev {
@@ -343,7 +374,7 @@ impl<'h> Evaluator<'h> {
UnOp::Neg => Value::number(-n),
UnOp::Plus => Value::number(n),
UnOp::Percent => Value::number(n / 100.0),
UnOp::At => Value::number(n),
UnOp::At | UnOp::Spill => Value::number(n),
},
op == UnOp::Plus,
))
@@ -696,8 +727,20 @@ impl<'h> Evaluator<'h> {
return err(CellError::Value);
}
let mut evaluated = Vec::with_capacity(args.len());
for a in args {
let ev = self.eval(a);
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
let mut criteria_shape = None;
for (i, a) in args.iter().enumerate() {
let mut ev = self.eval(a);
if resizes_sum_range(name, args.len()) {
if i == 0 {
criteria_shape = ev_shape(&ev);
} else if i == 2
&& let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev)
&& let [area] = areas.as_slice()
{
ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]);
}
}
let from_ref = matches!(ev, Ev::R(_));
let value = match ev {
Ev::R(areas) if areas.len() > 1 => {
@@ -881,7 +924,7 @@ impl<'h> Evaluator<'h> {
}
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
fn trim_lines(&self, a: &Area) -> RangeRef {
fn trim_lines(&mut self, a: &Area) -> RangeRef {
if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range }
}
@@ -1306,30 +1349,64 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value {
}
}
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
fn resizes_sum_range(name: &str, args: usize) -> bool {
args == 3 && matches!(name, "SUMIF" | "AVERAGEIF")
}
/// Height and width of a single-area reference or an array argument.
fn ev_shape(ev: &Ev) -> Option<(u32, u32)> {
match ev {
Ev::R(areas) => match areas.as_slice() {
[a] => Some((a.range.height(), a.range.width())),
_ => None,
},
Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))),
Ev::V(_) => Some((1, 1)),
Ev::L(_) => None,
}
}
/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns
/// as the criteria range (`height` × `width`), cut off at the sheet's edge.
pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef {
let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1);
let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1);
RangeRef::new(sum.start, CellRef::new(row, col))
}
/// Collects the static references of an expression with their sheets resolved; `dynamic` is
/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change.
pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
let mut out = Vec::new();
let mut dynamic = false;
e.walk(&mut |x| match x {
Expr::Ref(r) => {
let range = r.range();
match &r.sheet {
SheetSel::Current => out.push(Area { sheet, range }),
SheetSel::Named(n) => {
if let Some(i) = wb.sheet_index(n) {
out.push(Area { sheet: i, range });
}
}
SheetSel::Span(a, b) => {
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
for s in i.min(j)..=i.max(j) {
out.push(Area { sheet: s, range });
}
}
let push = |out: &mut Vec<Area>, r: &Reference, range: RangeRef| match &r.sheet {
SheetSel::Current => out.push(Area { sheet, range }),
SheetSel::Named(n) => {
if let Some(i) = wb.sheet_index(n) {
out.push(Area { sheet: i, range });
}
}
SheetSel::Span(a, b) => {
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
for s in i.min(j)..=i.max(j) {
out.push(Area { sheet: s, range });
}
}
}
};
e.walk(&mut |x| match x {
Expr::Ref(r) => push(&mut out, r, r.range()),
// The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when
// the criteria range's size isn't known statically, recalculate the formula every time.
Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) {
(Some(Expr::Ref(c)), Some(Expr::Ref(s))) => {
let (crit, sum) = (c.range(), s.range());
push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width()));
}
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
_ => dynamic = true,
},
Expr::Call(n, _)
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
{
@@ -1337,6 +1414,20 @@ pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
}
Expr::Name(_) | Expr::Struct(_) => dynamic = true,
Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true,
// A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between
// the references inside it, not only those references.
Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => {
let mut inner = precedents(wb, sheet, a).0;
inner.extend(precedents(wb, sheet, b).0);
let mut boxes: Vec<Area> = Vec::new();
for area in inner {
match boxes.iter_mut().find(|x| x.sheet == area.sheet) {
Some(x) => x.range = x.range.union(&area.range),
None => boxes.push(area),
}
}
out.extend(boxes);
}
_ => {}
});
(out, dynamic)
+626 -46
View File
@@ -1,13 +1,14 @@
//! Dependency graph and recalculation.
//!
//! The graph maps every formula cell to its static precedents (resolved areas). A reverse index
//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges in
//! per-column buckets (wide ranges in a separate list). After an edit, the dirty set is the
//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges as row
//! intervals per column (wide ranges per sheet), each an interval index so a lookup costs
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
//! calculation is on.
use std::collections::VecDeque;
use std::collections::{BTreeSet, VecDeque};
use std::hash::{BuildHasherDefault, Hasher};
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
@@ -51,19 +52,147 @@ struct Node {
dynamic: bool,
}
/// Ranges wider than this many columns go to the wide list instead of per-column buckets.
/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones.
const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
/// `a..=b`?" in O(log n + k).
///
/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query
/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one
/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops
/// dead entries once either grows, so mutations cost O(log n) amortised.
#[derive(Clone, Debug)]
struct Intervals<T> {
/// Sorted, without duplicates.
items: Vec<(u32, u32, T)>,
dead: Vec<bool>,
dead_count: usize,
/// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at
/// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included).
max_end: Vec<u32>,
/// Inserted since the last merge.
pending: BTreeSet<(u32, u32, T)>,
}
impl<T> Default for Intervals<T> {
fn default() -> Self {
Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() }
}
}
impl<T: Copy + Ord> Intervals<T> {
fn is_empty(&self) -> bool {
self.items.len() == self.dead_count && self.pending.is_empty()
}
fn insert(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& *d
{
*d = false;
self.dead_count -= 1;
}
return;
}
self.pending.insert(it);
}
fn remove(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& !*d
{
*d = true;
self.dead_count += 1;
}
return;
}
self.pending.remove(&it);
}
/// Merges pending insertions and drops dead entries when there are enough of them.
fn settle(&mut self) {
if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) {
return;
}
let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len());
all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it));
all.extend(std::mem::take(&mut self.pending));
// Two sorted runs: the stable sort merges them in linear time.
all.sort();
all.dedup();
let size = all.len().next_power_of_two();
let mut max_end = vec![0; 2 * size];
if let Some(leaves) = max_end.get_mut(size..) {
for (slot, it) in leaves.iter_mut().zip(&all) {
*slot = it.1;
}
}
for i in (1..size).rev() {
let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0));
if let Some(slot) = max_end.get_mut(i) {
*slot = m;
}
}
self.dead = vec![false; all.len()];
self.dead_count = 0;
self.items = all;
self.max_end = max_end;
}
/// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the
/// number of tree nodes visited.
fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize {
for it in &self.pending {
if it.0 <= b && it.1 >= a {
f(&it.2);
}
}
// Entries before `p` start at or before row `b`.
let p = self.items.partition_point(|it| it.0 <= b);
if p == 0 {
return 0;
}
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
}
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return 1;
}
if hi - lo <= 1 {
if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo))
&& it.1 >= a
{
f(&it.2);
}
return 1;
}
let mid = lo + (hi - lo) / 2;
1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f)
}
}
#[derive(Clone, Debug, Default)]
pub struct Graph {
nodes: HashMap<Key, Node>,
/// single cell → dependents
cell_deps: HashMap<Key, Vec<Key>>,
/// (sheet, col) → [(r0, r1, dependent)]
col_deps: HashMap<(usize, u32), Vec<(u32, u32, Key)>>,
/// (sheet, range, dependent)
wide_deps: Vec<(usize, RangeRef, Key)>,
cell_deps: HashMap<Key, HashSet<Key>>,
/// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column
col_deps: HashMap<(usize, u32), Intervals<Key>>,
/// sheet → row intervals of wider ranges, with their columns: (first, last, dependent)
wide_deps: HashMap<usize, Intervals<(u32, u32, Key)>>,
dynamic: HashSet<Key>,
/// Range indexes changed since the last [`Graph::settle`].
unsettled_cols: HashSet<(usize, u32)>,
unsettled_wide: HashSet<usize>,
}
impl Graph {
@@ -74,19 +203,44 @@ impl Graph {
self.nodes.is_empty()
}
fn insert(&mut self, k: Key, node: Node) {
self.remove(k);
for a in &node.areas {
/// Index entries of a formula's precedents, without duplicates: single cells, column
/// intervals and wide ranges.
fn entries(areas: &[Area]) -> (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) {
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
for a in areas {
if a.range.is_single() {
self.cell_deps.entry((a.sheet, a.range.start)).or_default().push(k);
cells.push((a.sheet, a.range.start));
} else if a.range.width() > WIDE {
self.wide_deps.push((a.sheet, a.range, k));
wide.push((a.sheet, a.range));
} else {
for c in a.range.start.col..=a.range.end.col {
self.col_deps.entry((a.sheet, c)).or_default().push((a.range.start.row, a.range.end.row, k));
cols.push((a.sheet, c, a.range.start.row, a.range.end.row));
}
}
}
cells.sort_unstable();
cells.dedup();
cols.sort_unstable();
cols.dedup();
wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end));
wide.dedup();
(cells, cols, wide)
}
fn insert(&mut self, k: Key, node: Node) {
self.remove(k);
let (cells, cols, wide) = Self::entries(&node.areas);
for c in cells {
self.cell_deps.entry(c).or_default().insert(k);
}
for (s, c, r0, r1) in cols {
self.col_deps.entry((s, c)).or_default().insert(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
for (s, r) in wide {
self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
if node.dynamic {
self.dynamic.insert(k);
}
@@ -95,54 +249,104 @@ impl Graph {
fn remove(&mut self, k: Key) {
let Some(old) = self.nodes.remove(&k) else { return };
for a in &old.areas {
if a.range.is_single() {
if let Some(v) = self.cell_deps.get_mut(&(a.sheet, a.range.start)) {
v.retain(|x| *x != k);
}
} else if a.range.width() > WIDE {
self.wide_deps.retain(|(_, _, d)| *d != k);
} else {
for c in a.range.start.col..=a.range.end.col {
if let Some(v) = self.col_deps.get_mut(&(a.sheet, c)) {
v.retain(|(_, _, d)| *d != k);
}
let (cells, cols, wide) = Self::entries(&old.areas);
for c in cells {
if let Some(v) = self.cell_deps.get_mut(&c) {
v.remove(&k);
if v.is_empty() {
self.cell_deps.remove(&c);
}
}
}
for (s, c, r0, r1) in cols {
if let Some(v) = self.col_deps.get_mut(&(s, c)) {
v.remove(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
}
for (s, r) in wide {
if let Some(v) = self.wide_deps.get_mut(&s) {
v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
}
self.dynamic.remove(&k);
}
/// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it,
/// just slower).
fn settle(&mut self) {
for key in std::mem::take(&mut self.unsettled_cols) {
if let Some(v) = self.col_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.col_deps.remove(&key);
}
}
}
for key in std::mem::take(&mut self.unsettled_wide) {
if let Some(v) = self.wide_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.wide_deps.remove(&key);
}
}
}
}
/// Formulas that read cell `c` of `sheet`.
pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec<Key>) {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend_from_slice(v);
out.extend(v.iter().copied());
}
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= c.row && c.row <= *r1).map(|(_, _, k)| *k));
v.query(c.row, c.row, &mut |k| out.push(*k));
}
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(c.row, c.row, &mut |(c0, c1, k)| {
if *c0 <= c.col && c.col <= *c1 {
out.push(*k);
}
});
}
out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.contains(c)).map(|(_, _, k)| *k));
}
/// Formulas that read any cell of `range`.
/// Formulas that read any cell of `range` (a formula can be listed more than once).
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
if range.count() <= 4096 {
for c in range.iter() {
self.dependents(sheet, c, out);
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
}
return;
}
for ((s, c), v) in &self.cell_deps {
if *s == sheet && range.contains(*c) {
out.extend_from_slice(v);
} else {
for ((s, c), v) in &self.cell_deps {
if *s == sheet && range.contains(*c) {
out.extend(v.iter().copied());
}
}
}
for ((s, col), v) in &self.col_deps {
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= range.end.row && range.start.row <= *r1).map(|(_, _, k)| *k));
let (r0, r1) = (range.start.row, range.end.row);
if (range.width() as usize) <= self.col_deps.len() {
for col in range.start.col..=range.end.col {
if let Some(v) = self.col_deps.get(&(sheet, col)) {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
} else {
for ((s, col), v) in &self.col_deps {
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
}
out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.intersects(&range)).map(|(_, _, k)| *k));
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(r0, r1, &mut |(c0, c1, k)| {
if *c0 <= range.end.col && range.start.col <= *c1 {
out.push(*k);
}
});
}
}
/// Precedent areas of a formula cell (for Trace Precedents).
@@ -177,6 +381,9 @@ struct PassHost<'a> {
in_progress: HashSet<Key>,
/// New spills found during the pass: anchor → array.
spills: HashMap<Key, std::sync::Arc<Array>>,
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
/// every row, so once per sheet rather than once per range read.
used: HashMap<usize, Option<RangeRef>>,
rng: &'a mut u64,
now: f64,
cycle: bool,
@@ -184,6 +391,27 @@ struct PassHost<'a> {
depth: usize,
}
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is
/// recalculated in this pass, and so may spill elsewhere now).
fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
let Some(sh) = host.wb.sheet(sheet) else { return false };
let end =
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
let range = RangeRef::new(anchor, end);
let own = sh.spill_ranges.get(&anchor);
let spilled_over = |c: CellRef| {
!own.is_some_and(|o| o.contains(c))
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
};
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.merges.iter().any(|m| m.intersects(&range))
|| sh.tables.iter().any(|t| t.range.contains(anchor))
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some()))
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
}
impl PassHost<'_> {
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
@@ -204,27 +432,96 @@ impl PassHost<'_> {
let mut ev = Evaluator::new(self, k.0, k.1);
ev.value(&expr)
};
// A legacy array formula fills the range it was entered in.
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
let v = match legacy {
Some(range) => fit_to_range(v, range),
None => v,
};
self.depth -= 1;
self.in_progress.remove(&k);
self.pending.remove(&k);
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
// of their own, which stay blank.
let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY"));
let v = match v {
Value::Array(a) if a.rows == 1 && a.cols == 1 => a.data.first().cloned().unwrap_or_default(),
Value::Array(a) => {
self.spills.insert(k, a.clone());
let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) {
a
} else {
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
};
if a.rows > 1 || a.cols > 1 {
self.spills.insert(k, a.clone());
}
a.data.first().cloned().unwrap_or_default()
}
Value::Empty => Value::Number(0.0),
v => v,
};
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
Value::Error(CellError::Spill)
}
_ => v,
};
self.results.insert(k, v.clone());
v
}
}
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
/// Excel does: a single row or column repeats across the range, a single value fills it, cells
/// past the result show `#N/A`, and a result larger than the range is cut off.
fn fit_to_range(v: Value, range: RangeRef) -> Value {
if range.count() > crate::eval::MAX_CELLS {
return Value::Error(CellError::Num);
}
let (h, w) = (range.height() as usize, range.width() as usize);
let Value::Array(a) = v else {
return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) };
};
if h * w == 1 {
return a.data.first().cloned().unwrap_or_default();
}
let mut data = Vec::with_capacity(h * w);
for r in 0..h {
for c in 0..w {
let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c });
data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA)));
}
}
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
let k = (sheet, anchor);
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied();
}
// Recalculated in this pass: its new array, unless something blocks it (#SPILL!).
self.compute(k);
let arr = self.spills.get(&k)?;
let end = CellRef::new(
anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)),
anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
let range = RangeRef::new(anchor, end);
let sh = self.wb.sheet(sheet)?;
let blocked = end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty()))
|| sh.merges.iter().any(|m| m.intersects(&range));
(!blocked).then_some(range)
}
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
let k = (sheet, c);
if self.pending.contains(&k) || self.results.contains_key(&k) {
@@ -301,6 +598,10 @@ impl Host for PassHost<'_> {
}
out
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
}
fn now_serial(&self) -> f64 {
self.now
}
@@ -340,17 +641,29 @@ impl Calc {
}
}
}
self.graph.settle();
}
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
pub fn recalc_all(&mut self, wb: &mut Workbook) {
self.rebuild(wb);
// Spills are laid out again from the formulas where they are now (a structural edit moves
// or deletes the formulas, not the values they spilled).
for i in 0..wb.sheets.len() {
if wb.sheet(i).is_some_and(|s| !s.spill_ranges.is_empty() || !s.spill.is_empty())
&& let Some(sh) = wb.sheet_mut(i)
{
sh.spill.clear();
sh.spill_ranges.clear();
}
}
let all: Vec<Key> = self.graph.nodes.keys().copied().collect();
self.run(wb, all, true);
}
/// Updates the graph for cells whose content changed and recalculates what depends on them.
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
for &k in changed {
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
match formula.and_then(|f| f.expr()) {
@@ -360,6 +673,15 @@ impl Calc {
}
None => {
self.graph.remove(k);
// A formula replaced by a constant (or cleared) takes its spilled values with it.
if let Some(sh) = wb.sheet_mut(k.0)
&& let Some(r) = sh.spill_ranges.remove(&k.1)
{
for c in r.iter() {
sh.spill.remove(&c);
}
cleared.push((k.0, r));
}
// A formula that doesn't parse evaluates to #NAME?.
if let Some(cell) = wb.sheet_mut(k.0).and_then(|s| s.cells.get_mut(k.1))
&& cell.formula.is_some()
@@ -369,6 +691,7 @@ impl Calc {
}
}
}
self.graph.settle();
if wb.calc.mode == CalcMode::Manual {
// Only the edited formulas themselves are evaluated.
let own: Vec<Key> = changed.iter().copied().filter(|k| self.graph.nodes.contains_key(k)).collect();
@@ -376,6 +699,7 @@ impl Calc {
return;
}
let mut seeds: Vec<Key> = changed.to_vec();
let mut emptied: Vec<Key> = Vec::new();
// Typing into (or clearing) a cell a dynamic array spills over (or would spill over)
// re-evaluates the anchor, which may now be blocked or unblocked.
for &k in changed {
@@ -390,6 +714,20 @@ impl Calc {
seeds.push((k.0, c));
}
}
if !sh.cells.has(k.1) {
match emptied.iter_mut().find(|x| x.0 == k.0) {
Some(x) => x.1 = CellRef::new(x.1.row.max(k.1.row), x.1.col.max(k.1.col)),
None => emptied.push(k),
}
}
}
}
// An emptied cell can unblock an anchor any distance above or to the left of it.
for (si, end) in emptied {
for &(s, c) in self.graph.nodes.keys() {
if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) {
seeds.push((s, c));
}
}
}
// Spill areas of changed anchors also change.
@@ -398,6 +736,9 @@ impl Calc {
seeds.extend(r.iter().take(65536).map(|c| (k.0, c)));
}
}
for (si, r) in &cleared {
seeds.extend(r.iter().take(65536).map(|c| (*si, c)));
}
let t0 = prof_now();
let dirty = self.dirty_closure(wb, &seeds);
let t1 = prof_now();
@@ -443,6 +784,7 @@ impl Calc {
/// Evaluates the dirty set and writes results (and spills) back.
fn run(&mut self, wb: &mut Workbook, mut dirty: Vec<Key>, full: bool) {
let mut dynamic_again = !self.graph.dynamic.is_empty();
for _round in 0..8 {
if dirty.is_empty() {
break;
@@ -466,6 +808,7 @@ impl Calc {
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now,
cycle: false,
@@ -486,6 +829,7 @@ impl Calc {
self.circular = cycles;
// Write back.
let mut spill_changes: Vec<Key> = Vec::new();
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
for (k, v) in results {
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
if let Some(cell) = sheet.cells.get_mut(k.1)
@@ -514,7 +858,8 @@ impl Calc {
let blocked = end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c)))
|| sheet.merges.iter().any(|m| m.intersects(&range));
|| sheet.merges.iter().any(|m| m.intersects(&range))
|| sheet.tables.iter().any(|t| t.range.contains(k.1));
if blocked {
if let Some(cell) = sheet.cells.get_mut(k.1) {
cell.value = Value::Error(CellError::Spill);
@@ -532,7 +877,30 @@ impl Calc {
if let Some(r) = changed_range {
let mut deps = Vec::new();
self.graph.dependents_of_range(k.0, r, &mut deps);
spill_changes.extend(deps);
spill_changes.extend(deps.into_iter().filter(|d| d != k));
}
if let Some(o) = old
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
{
freed.push((k.0, o));
}
}
// Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read
// a spill area before it was laid out: they are evaluated once more.
if dynamic_again && !spills.is_empty() {
dynamic_again = false;
spill_changes.extend(self.graph.dynamic.iter().copied());
}
// An area a formula no longer spills over may unblock another formula's array.
for (si, o) in freed {
for &(s, c) in self.graph.nodes.keys() {
if s == si
&& c.row <= o.end.row
&& c.col <= o.end.col
&& wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill))
{
spill_changes.push((s, c));
}
}
}
if full {
@@ -543,7 +911,6 @@ impl Calc {
}
spill_changes.sort_by_key(|(s, c)| (*s, c.row, c.col));
spill_changes.dedup();
spill_changes.retain(|k| !spills.contains_key(k));
if spill_changes.is_empty() {
break;
}
@@ -553,6 +920,7 @@ impl Calc {
}
/// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date.
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
pub fn now_serial() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -582,6 +950,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
pending: HashSet::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: now_serial(),
cycle: false,
@@ -592,6 +961,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
ev.value(expr)
}
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
fn prof_now() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -602,3 +972,213 @@ fn prof_now() -> f64 {
0.0
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use super::{Calc, Intervals, Key};
/// xorshift64 for repeatable pseudo-random cases.
fn rng(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
fn found(iv: &Intervals<u32>, a: u32, b: u32) -> Vec<u32> {
let mut v = Vec::new();
iv.query(a, b, &mut |t| v.push(*t));
v.sort_unstable();
v
}
#[test]
fn intervals_match_a_linear_scan() {
let mut iv: Intervals<u32> = Intervals::default();
let mut model: Vec<(u32, u32, u32)> = Vec::new();
let mut s = 0x9E37_79B9_7F4A_7C15;
for step in 0..20_000u32 {
let roll = rng(&mut s) % 10;
if roll < 6 || model.is_empty() {
let r0 = (rng(&mut s) % 500) as u32;
let r1 = r0 + (rng(&mut s) % 40) as u32;
if !model.contains(&(r0, r1, step)) {
iv.insert(r0, r1, step);
model.push((r0, r1, step));
}
} else {
let i = (rng(&mut s) % model.len() as u64) as usize;
let (r0, r1, t) = model.swap_remove(i);
iv.remove(r0, r1, t);
if roll == 9 {
// Removed and put back before the next merge.
iv.insert(r0, r1, t);
model.push((r0, r1, t));
}
}
if rng(&mut s).is_multiple_of(50) {
iv.settle();
}
if step.is_multiple_of(97) {
let a = (rng(&mut s) % 560) as u32;
let b = a + (rng(&mut s) % 30) as u32;
let mut want: Vec<u32> = model.iter().filter(|(r0, r1, _)| *r0 <= b && *r1 >= a).map(|(_, _, t)| *t).collect();
want.sort_unstable();
assert_eq!(found(&iv, a, b), want, "rows {a}..={b} at step {step}");
}
}
iv.settle();
for row in 0..560 {
let mut want: Vec<u32> = model.iter().filter(|(r0, r1, _)| *r0 <= row && row <= *r1).map(|(_, _, t)| *t).collect();
want.sort_unstable();
assert_eq!(found(&iv, row, row), want, "row {row}");
}
for (r0, r1, t) in std::mem::take(&mut model) {
iv.remove(r0, r1, t);
}
iv.settle();
assert!(iv.is_empty());
}
#[test]
fn interval_queries_cost_log_n_plus_matches() {
// 40k one-row ranges (like =SUM(E1:J1) down a column) and 1k ranges over everything:
// a point query visits O(log n) tree nodes per match, not every range in the column.
let n = 40_000u32;
let mut iv: Intervals<u32> = Intervals::default();
for r in 0..n {
iv.insert(r, r, r);
}
for t in 0..1_000 {
iv.insert(0, n, n + t);
}
iv.settle();
let mut worst = 0;
for row in (0..n).step_by(7) {
let mut k = 0;
let visited = iv.query(row, row, &mut |_| k += 1);
assert_eq!(k, 1_001);
worst = worst.max(visited);
}
// 1,001 matches in a tree of depth 16: well under 4 nodes per match plus the path.
assert!(worst < 4 * 1_001 + 64, "visited {worst} nodes");
let mut k = 0;
let visited = iv.query(n + 1, n + 5, &mut |_| k += 1);
assert_eq!(k, 0);
assert!(visited < 64, "visited {visited} nodes for no match");
}
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
fn sum_rows(rows: u32) -> Workbook {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
for r in 0..rows {
for c in 4..10 {
sh.cells.set(CellRef::new(r, c), Cell::value(Value::Number(f64::from(r % 97 + c))));
}
sh.cells.set(CellRef::new(r, 10), Cell::formula(Formula::new(&format!("=SUM(E{0}:J{0})", r + 1))));
}
}
wb
}
/// Opening (full recalc), changing every value of a column, and re-entering every formula.
#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm
fn open_and_bulk_edit(rows: u32) -> Duration {
let mut wb = sum_rows(rows);
let mut calc = Calc::new();
let t = Instant::now();
calc.recalc_all(&mut wb);
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
if let Some(sh) = wb.sheet_mut(0) {
for (_, c) in &values {
sh.cells.set(*c, Cell::value(Value::Number(1.0)));
}
}
calc.cells_changed(&mut wb, &values);
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
calc.cells_changed(&mut wb, &formulas);
let elapsed = t.elapsed();
let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10)));
let r = rows - 1;
assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::<u32>()))));
elapsed
}
#[test]
fn range_formulas_recalc_and_bulk_edit_correctly() {
// The values behind the timing test below, checked on every run.
open_and_bulk_edit(500);
}
#[test]
#[ignore = "timing-sensitive; run with --ignored in release"]
#[allow(clippy::disallowed_methods)]
fn recalc_with_range_formulas_scales_linearly() {
// Each row's SUM reads a range in the same columns. Recalculating on open used to walk
// every row per range read (the used range), and finding a cell's dependents scanned
// every range in its column: 4x the rows took 16x the time. Linear is 4x; allow 8x.
let best = |rows| (0..3).map(|_| open_and_bulk_edit(rows)).min().unwrap_or_default();
let small = best(5_000);
let large = best(20_000);
assert!(large < small * 8 + Duration::from_millis(100), "5k rows {small:?}, 20k rows {large:?}");
}
#[test]
fn range_dependents_after_edits() {
// Formulas that read ranges, wide ranges and whole columns, found again after edits.
let mut wb = Workbook::new();
let mut calc = Calc::new();
let set = |wb: &mut Workbook, calc: &mut Calc, at: &str, input: &str| {
let c = CellRef::parse(at).unwrap();
let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(Value::Number(input.parse().unwrap())) };
wb.sheet_mut(0).unwrap().cells.set(c, cell);
calc.cells_changed(wb, &[(0, c)]);
};
let num = |wb: &Workbook, at: &str| {
let v = wb.sheet(0).unwrap().value(CellRef::parse(at).unwrap());
v.as_f64().unwrap_or_else(|| panic!("{at} = {v:?}"))
};
set(&mut wb, &mut calc, "A1", "1");
set(&mut wb, &mut calc, "B2", "2");
set(&mut wb, &mut calc, "CZ3", "3");
set(&mut wb, &mut calc, "DA5", "=SUM(A1:B2)");
set(&mut wb, &mut calc, "DA6", "=SUM(A1:CZ3)");
set(&mut wb, &mut calc, "DA7", "=SUM(B:B)");
set(&mut wb, &mut calc, "DA8", "=SUM(2:2)");
assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (3.0, 6.0, 2.0, 2.0));
set(&mut wb, &mut calc, "B2", "20");
assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (21.0, 24.0, 20.0, 20.0));
// Re-entering a formula with another range drops the old one.
set(&mut wb, &mut calc, "DA5", "=SUM(A1:A2)");
set(&mut wb, &mut calc, "B2", "5");
assert_eq!(num(&wb, "DA5"), 1.0);
let mut deps = Vec::new();
calc.graph.dependents(0, CellRef::parse("B2").unwrap(), &mut deps);
deps.sort_unstable();
let at = |s: &str| (0, CellRef::parse(s).unwrap());
assert_eq!(deps, vec![at("DA6"), at("DA7"), at("DA8")]);
deps.clear();
calc.graph.dependents_of_range(0, RangeRef::new(CellRef::parse("A1").unwrap(), CellRef::parse("A9").unwrap()), &mut deps);
deps.sort_unstable();
deps.dedup();
assert_eq!(deps, vec![at("DA5"), at("DA6"), at("DA8")]);
set(&mut wb, &mut calc, "CZ3", "30");
assert_eq!(num(&wb, "DA6"), 36.0);
// Ranges on another sheet.
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
set(&mut wb, &mut calc, "DA9", "=SUM(Data!A1:A3)+SUM(Data!A:CZ)");
assert_eq!(num(&wb, "DA9"), 0.0);
wb.sheet_mut(1).unwrap().cells.set(CellRef::parse("A2").unwrap(), Cell::value(Value::Number(4.0)));
calc.cells_changed(&mut wb, &[(1, CellRef::parse("A2").unwrap())]);
assert_eq!(num(&wb, "DA9"), 8.0);
deps.clear();
calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps);
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
}
}
+252
View File
@@ -33,6 +33,40 @@ impl T {
}
}
#[test]
fn spill_range_operator() {
let mut t = T::new();
t.set("A1", "3");
t.set("B1", "=SEQUENCE(A1)");
t.set("C1", "=SUM(B1#)");
t.set("C2", "=ROWS(B1#)");
assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0));
// Growing and shrinking the spill recalculates its users.
t.set("A1", "5");
assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0));
t.set("A1", "2");
assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0));
// A blocked spill, or a cell that doesn't spill, gives #REF!.
t.set("B2", "x");
assert_eq!(t.get("C1"), Value::Error(CellError::Ref));
t.set("B2", "");
assert_eq!(t.num("C1"), 3.0);
t.set("D1", "=SUM(A1#)");
assert_eq!(t.get("D1"), Value::Error(CellError::Ref));
// Through a defined name.
t.wb.names.push(gridcraft_model::DefinedName {
name: "Seq".into(),
scope: None,
formula: "Sheet1!$B$1#".into(),
comment: String::new(),
hidden: false,
});
t.set("E1", "=SUM(Seq)*10");
assert_eq!(t.num("E1"), 30.0);
t.set("A1", "4");
assert_eq!(t.num("E1"), 100.0);
}
#[test]
fn arithmetic_and_dependencies() {
let mut t = T::new();
@@ -105,6 +139,72 @@ fn operators() {
assert_eq!(t.num("A7"), 0.0);
}
#[test]
fn blocked_spill_reads_as_spill_error() {
// Calculated in the same pass, a formula over a blocked spill read the array that couldn't
// spill.
let mut t = T::new();
let s = t.wb.sheet_mut(0).unwrap();
for a in ["A1", "B1", "C1", "A2", "C2", "A3", "B3", "C3"] {
s.cells.set(c(a), Cell::value(Value::Number(1.0)));
}
s.cells.set(c("B2"), Cell::formula(Formula::new("=SEQUENCE(2,2,9)")));
s.cells.set(c("E1"), Cell::formula(Formula::new("=SUM(A1:C3)")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("B2"), Value::Error(CellError::Spill));
assert_eq!(t.get("E1"), Value::Error(CellError::Spill));
assert_eq!(t.num("C3"), 1.0);
}
#[test]
fn blocked_spill_read_through_offset() {
// The reader's reference is only known while evaluating, so it isn't in the dependency graph.
let mut t = T::new();
t.set("E4", "5");
t.set("E8", "=SUM(OFFSET(E2,0,0,2,2))");
t.set("E3", "=SEQUENCE(2,2,3)");
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("E3"), Value::Error(CellError::Spill));
assert_eq!(t.get("E8"), Value::Error(CellError::Spill));
}
#[test]
fn spill_into_the_old_area_of_a_blocked_neighbour() {
// C7's array becomes blocked by C8, whose array then takes over C7's old area.
let mut t = T::new();
t.set("C7", "=SEQUENCE(2,2)");
t.set("C8", "=SEQUENCE(2,2)");
assert_eq!(t.get("C7"), Value::Error(CellError::Spill));
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn arrays_do_not_spill_in_tables() {
// Excel: spilled array formulas aren't supported in tables.
let mut t = T::new();
t.wb.sheet_mut(0).unwrap().tables.push(gridcraft_model::Table {
id: 1,
name: "Table1".into(),
range: gridcraft_core::RangeRef::parse("B3:C6").unwrap(),
header_row: true,
totals_row: false,
columns: vec![],
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: false,
});
t.set("C4", "=SEQUENCE(2)");
t.set("E1", "=SEQUENCE(4)");
t.set("H1", "=SUM(C4:C5)");
assert_eq!(t.get("C4"), Value::Error(CellError::Spill));
assert_eq!(t.get("C5"), Value::Empty);
assert_eq!(t.get("H1"), Value::Error(CellError::Spill));
assert_eq!(t.num("E4"), 4.0);
}
#[test]
fn ifs_and_lazy() {
let mut t = T::new();
@@ -158,6 +258,40 @@ fn spills() {
assert_eq!(t.num("D2"), 40.0);
}
#[test]
fn references_keep_their_size_past_the_used_range() {
let mut t = T::new();
t.set("A1", "5");
t.set("A2", "6");
t.set("C1", "=ROWS(A1:A10*1)");
t.set("C2", "=SUMPRODUCT(--(A1:A10=\"\"))");
t.set("C3", "=COUNTBLANK(A1:A10)");
t.set("C4", "=COUNTIF(A1:A10,\"\")");
t.set("C5", "=COLUMNS(TRANSPOSE(A1:A10))");
for (at, want) in [("C1", 10.0), ("C2", 8.0), ("C3", 8.0), ("C4", 8.0), ("C5", 10.0)] {
assert_eq!(t.num(at), want, "{at}");
}
// Whole columns are still read up to the used range.
t.set("D1", "=ROWS(FILTER(A:A,A:A<>\"\"))");
assert_eq!(t.num("D1"), 2.0);
}
#[test]
fn spilled_blank_cells_show_zero() {
let mut t = T::new();
t.set("A1", "5");
t.set("A3", "7");
t.set("C1", "=A1:A4");
assert_eq!(t.get("C2"), Value::Number(0.0));
assert_eq!(t.get("C4"), Value::Number(0.0));
t.set("D1", "=COUNT(C1:C4)");
assert_eq!(t.num("D1"), 4.0);
t.set("E1", "=A2:A3");
assert_eq!(t.get("E1"), Value::Number(0.0));
t.set("F1", "=SORT(A1:A3)");
assert_eq!(t.get("F3"), Value::Number(0.0));
}
#[test]
fn let_lambda() {
let mut t = T::new();
@@ -185,6 +319,33 @@ fn names_and_sheets() {
assert_eq!(t.num("A1"), 5.0);
}
#[test]
fn legacy_array_formulas_fill_their_range() {
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
let cse = |t: &mut T, at: &str, formula: &str, range: &str| {
let mut f = Formula::new(formula);
f.array = gridcraft_core::RangeRef::parse(range);
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(f));
t.calc.cells_changed(&mut t.wb, &[(0, c(at))]);
};
// Past the result: #N/A. A single column repeats across the range.
cse(&mut t, "C1", "=A1:A2*2", "C1:D3");
cse(&mut t, "F1", "=A1:A2*2", "F1");
cse(&mut t, "G1", "=5", "G1:G2");
assert_eq!(t.num("C2"), 4.0);
assert_eq!(t.num("D2"), 4.0);
assert_eq!(t.get("C3"), Value::Error(CellError::NA));
// A smaller range shows only part of the result and never spills.
assert_eq!(t.num("F1"), 2.0);
assert_eq!(t.get("F2"), Value::Empty);
assert_eq!(t.num("G2"), 5.0);
// A range too large to fill is an error, not an allocation of every cell.
cse(&mut t, "H1", "=1", "H1:XFD1048576");
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
}
#[test]
fn text_function() {
let mut t = T::new();
@@ -215,3 +376,94 @@ fn recalc_all_after_load() {
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A3"), 12.0);
}
#[test]
fn sumif_sum_range_takes_the_criteria_size() {
// Excel adds up the range that starts at sum_range's top-left cell with the criteria range's
// height and width, whatever size sum_range has.
let mut t = T::new();
for (r, (k, v)) in [("a", 1), ("b", 2), ("a", 3)].iter().enumerate() {
t.set(&format!("A{}", r + 1), k);
t.set(&format!("B{}", r + 1), &v.to_string());
}
t.set("D1", "=SUMIF(A1:A3,\"a\",B1)");
assert_eq!(t.num("D1"), 4.0);
t.set("D2", "=AVERAGEIF(A1:A3,\"a\",B1)");
assert_eq!(t.num("D2"), 2.0);
t.set("D3", "=SUMIF(A1:A3,\"a\",B1:B2)");
assert_eq!(t.num("D3"), 4.0);
t.set("D4", "=SUMIF(A1:A3,\"a\",B1:F1)");
assert_eq!(t.num("D4"), 4.0);
t.set("D5", "=SUMIF(A1:A3,\"a\",B1:B99)");
assert_eq!(t.num("D5"), 4.0);
t.set("D6", "=SUMIF(A:A,\"a\",B1)");
assert_eq!(t.num("D6"), 4.0);
t.set("D7", "=SUMIF(A1:A3,\"a\",$B$2)");
assert_eq!(t.num("D7"), 2.0); // B2:B4: rows 1 and 3 match → B2 + B4 (empty)
t.set("D8", "=SUMIF(A1:A3,\"a\")");
assert_eq!(t.num("D8"), 0.0);
// A horizontal criteria range resizes across.
t.set("H1", "1");
t.set("I1", "0");
t.set("J1", "1");
t.set("H2", "10");
t.set("I2", "20");
t.set("J2", "30");
t.set("D9", "=SUMIF(H1:J1,1,H2)");
assert_eq!(t.num("D9"), 40.0);
// The resized cells are precedents: editing one recalculates.
t.set("B3", "5");
assert_eq!(t.num("D1"), 6.0);
assert_eq!(t.num("D2"), 3.0);
assert_eq!(t.num("D3"), 6.0);
assert_eq!(t.num("D4"), 6.0);
assert_eq!(t.num("D6"), 6.0);
// SUMIFS and AVERAGEIFS don't resize: different sizes are #VALUE!.
t.set("D11", "=SUMIFS(B1,A1:A3,\"a\")");
assert_eq!(t.get("D11"), Value::Error(CellError::Value));
t.set("D12", "=AVERAGEIFS(B1:B2,A1:A3,\"a\")");
assert_eq!(t.get("D12"), Value::Error(CellError::Value));
t.set("D13", "=SUMIFS(B1:B3,A1:A3,\"a\")");
assert_eq!(t.num("D13"), 6.0);
}
#[test]
fn sumif_resized_sum_range_stops_at_the_sheet_edge() {
let mut t = T::new();
t.set("A1", "a");
t.set("A2", "a");
t.set("B1048575", "5");
t.set("B1048576", "7");
t.set("C1", "=SUMIF(A1:A2,\"a\",B1048576)");
assert_eq!(t.num("C1"), 7.0);
t.set("C2", "=SUMIF(A1:A2,\"a\",B1048575)");
assert_eq!(t.num("C2"), 12.0);
t.set("C3", "=SUMIF(A1:B1,\"a\",XFD1)");
assert_eq!(t.num("C3"), 0.0);
}
#[test]
fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() {
let mut t = T::new();
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
t.wb.names.push(gridcraft_model::DefinedName {
name: "Amounts".into(),
scope: None,
formula: "Data!$B$1".into(),
comment: String::new(),
hidden: false,
});
for (r, k) in ["a", "b", "a"].iter().enumerate() {
t.set(&format!("A{}", r + 1), k);
t.wb.sheet_mut(1).unwrap().set_value(CellRef::new(r as u32, 1), Value::Number((r + 1) as f64));
}
t.calc.rebuild(&t.wb);
t.set("C1", "=SUMIF(A1:A3,\"a\",Data!B1)");
assert_eq!(t.num("C1"), 4.0);
t.set("C2", "=AVERAGEIF(A1:A3,\"a\",Amounts)");
assert_eq!(t.num("C2"), 2.0);
t.wb.sheet_mut(1).unwrap().set_value(c("B3"), Value::Number(9.0));
t.calc.cells_changed(&mut t.wb, &[(1, c("B3"))]);
assert_eq!(t.num("C1"), 10.0);
assert_eq!(t.num("C2"), 5.0);
}
+3
View File
@@ -138,6 +138,8 @@ pub struct SeriesData {
pub kind: ChartKind,
/// Plotted against the secondary (right) value axis.
pub secondary: bool,
/// Draw line segments as a smoothed (spline) curve.
pub smooth: bool,
/// Number format code of the source values ("General" by default).
pub number_format: String,
}
@@ -152,6 +154,7 @@ impl Default for SeriesData {
color: [0x15, 0x60, 0x82, 0xFF],
kind: ChartKind::ColumnClustered,
secondary: false,
smooth: false,
number_format: "General".into(),
}
}
+78 -4
View File
@@ -194,6 +194,7 @@ fn sanitize(d: &ChartData) -> ChartData {
color: s.color,
kind: s.kind,
secondary: s.secondary,
smooth: s.smooth,
number_format: s.number_format.clone(),
})
.collect(),
@@ -545,8 +546,9 @@ fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
out
}
/// Draws a polyline split at gaps (`None`), decimated when long.
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
let mut run: Vec<[f32; 2]> = Vec::new();
for p in pts.iter().chain(std::iter::once(&None)) {
match p {
@@ -554,6 +556,10 @@ fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
None => {
if run.len() >= 2 {
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
let r = match smooth {
Some(plot) => smooth_run(r, plot),
None => r,
};
ctx.line(r, color, width);
} else {
run.clear();
@@ -563,6 +569,48 @@ fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
}
}
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
/// duplicated so the curve passes through the first and last points, and the curve is clamped
/// to the `plot` box (a spline overshoots its data near sharp turns).
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
let n = pts.len();
if n < 3 {
return pts;
}
let cap = MAX_LINE_PTS * 2;
let steps = (cap / n).clamp(1, 100);
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
let at = |i: isize| -> [f32; 2] {
let j = i.clamp(0, n as isize - 1) as usize;
pts.get(j).copied().unwrap_or([0.0, 0.0])
};
let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right()));
let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom()));
let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)];
for i in 0..n.saturating_sub(1) {
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
for s in 0..steps {
let t = s as f32 / steps as f32;
out.push(clip(catmull(p0, p1, p2, p3, t)));
}
}
out.push(clip(at(n as isize - 1)));
out
}
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
let t2 = t * t;
let t3 = t2 * t;
let mut o = [0.0f32; 2];
for k in 0..2 {
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
}
o
}
fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
let mut seen = HashSet::new();
for p in pts {
@@ -836,7 +884,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
draw_runs(ctx, &pts, s.color, 2.25);
draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot));
let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);
if k == ChartKind::LineMarkers && cw >= 3.0 {
draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);
@@ -914,7 +962,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
} else {
let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines;
if lines {
draw_runs(ctx, &px, s.color, 2.0);
draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot));
}
if !lines || px.len() <= 500 {
draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color);
@@ -1468,3 +1516,29 @@ pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> {
})
.collect()
}
#[cfg(test)]
mod smooth_tests {
use super::*;
#[test]
fn long_runs_are_smoothed_uniformly_within_the_cap() {
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
let out = smooth_run(pts, plot);
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
assert_eq!(out.len(), 1499 * 2 + 1);
assert_eq!(out.last().copied(), Some([2998.0, 80.0]));
}
#[test]
fn spline_overshoot_is_clamped_to_the_plot() {
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
// A step right at the plot edges: Catmull-Rom overshoots past both.
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
let out = smooth_run(pts, plot);
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
}
+13 -2
View File
@@ -153,7 +153,8 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
let kind = effective_kind(chart.kind, s.kind, i);
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
(Some(f), true) => Some(nums(wb, sheet, f)),
// X values with text in them (labels) are ignored: the points are numbered 1..n.
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
_ => None,
};
if categories.is_none()
@@ -165,7 +166,17 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
series.push(SeriesData { name, values, x, sizes, color, kind, secondary: s.secondary, number_format: ref_format(wb, sheet, &s.values) });
series.push(SeriesData {
name,
values,
x,
sizes,
color,
kind,
secondary: s.secondary,
smooth: s.smooth,
number_format: ref_format(wb, sheet, &s.values),
});
}
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
+93 -2
View File
@@ -60,6 +60,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat
color,
kind,
secondary: false,
smooth: false,
number_format: "$#,##0".into(),
}
}
@@ -223,6 +224,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
},
Series {
name: None,
@@ -232,6 +234,7 @@ fn resolve_from_workbook() {
color: Some(Color::rgb(1, 2, 3)),
secondary: true,
kind: None,
smooth: false,
},
Series {
name: Some("Literal".into()),
@@ -241,6 +244,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
},
];
let d = resolve(&wb, 0, &c);
@@ -269,8 +273,26 @@ fn resolve_from_workbook() {
// Combo kinds and scatter X values.
let mut c = chart(ChartKind::Combo);
c.series = vec![
Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: false, kind: None },
Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: true, kind: None },
Series {
name: None,
categories: None,
values: "Sales!B2:B4".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
},
Series {
name: None,
categories: None,
values: "Sales!B2:B4".into(),
bubble_sizes: None,
color: None,
secondary: true,
kind: None,
smooth: false,
},
];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].kind, ChartKind::ColumnClustered);
@@ -284,6 +306,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
@@ -299,6 +322,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
}];
let d = resolve(&wb, 7, &c);
assert_eq!(d.series.len(), 1);
@@ -314,6 +338,29 @@ fn palette_beyond_six_series() {
assert_ne!(p[6], p[12]);
}
#[test]
fn scatter_with_text_x_values_numbers_the_points() {
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
for (i, (label, v)) in [("North", 3.0), ("South", 5.0), ("East", 4.0)].iter().enumerate() {
s.set_value(CellRef::new(i as u32, 0), Value::from(*label));
s.set_value(CellRef::new(i as u32, 1), Value::Number(*v));
s.set_value(CellRef::new(i as u32, 2), Value::Number(i as f64 * 10.0));
}
for kind in [ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble] {
let mut c = chart(kind);
c.series = vec![Series { categories: Some("Sheet1!$A$1:$A$3".into()), values: "Sheet1!$B$1:$B$3".into(), ..Default::default() }];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x, None, "{kind:?}");
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
// Numeric X values are kept.
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
assert_eq!(resolve(&wb, 0, &c).series[0].x, Some(vec![Some(0.0), Some(10.0), Some(20.0)]));
}
}
fn pixel(img: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
let i = ((y * w + x) * 4) as usize;
[img[i], img[i + 1], img[i + 2], img[i + 3]]
@@ -450,8 +497,51 @@ fn thousands_of_points_are_decimated() {
}
}
#[test]
fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
let m = ApproxMeasure;
let cats = ["a", "b", "c", "d"].map(String::from).to_vec();
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = smooth;
let d = ChartData { categories: cats.clone(), series: vec![s] };
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
.iter()
.filter_map(|p| match p {
Prim::Line { pts, .. } => Some(pts.len()),
_ => None,
})
.max()
.unwrap_or(0)
};
let straight = line_pts(false, &m);
let curved = line_pts(true, &m);
assert_eq!(straight, 4, "straight polyline keeps one point per datum");
assert!(curved > straight, "smooth line interpolates extra points: {curved} vs {straight}");
// Rendering either way must stay finite and inside the canvas.
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
let d = ChartData { categories: cats, series: vec![s] };
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
}
#[test]
fn smooth_line_splits_at_gaps() {
let m = ApproxMeasure;
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
s.values[1] = None; // gap between two data points
let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
// Two separate runs (before/after the gap) → at least two line primitives.
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
assert!(lines >= 2, "a gap must split the smoothed line into runs, got {lines}");
assert_sane(&prims, 600.0, 400.0);
}
/// Small deterministic PRNG (xorshift) for the fuzz test.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
@@ -491,6 +581,7 @@ fn fuzz_random_data_and_sizes() {
color: [(r.next() % 256) as u8, 100, 50, 255],
kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize],
secondary: r.next().is_multiple_of(3),
smooth: r.next().is_multiple_of(4),
number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(),
})
.collect();
+70 -14
View File
@@ -69,26 +69,34 @@ pub fn days_in_month(y: i32, m: u32) -> u32 {
}
}
/// Days in a month of a date system's calendar: like [`days_in_month`], except that February
/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29).
pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
}
/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31.
/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
/// `None` before the epoch or after 9999-12-31.
pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
// DATE(): years 0–1899 are added to 1900.
let mut y = if (0..1900).contains(&year) { year + 1900 } else { year };
let m0 = month - 1;
y += m0.div_euclid(12);
let y = if (0..1900).contains(&year) { year + 1900 } else { year };
let m0 = month.checked_sub(1)?;
let y = y.checked_add(m0.div_euclid(12))?;
let m = m0.rem_euclid(12) + 1;
if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 {
return None;
}
let base = days_from_civil(y as i32, m as u32, 1) + day - 1;
let serial = match sys {
DateSystem::D1900 => {
let s = base - UNIX_1899_12_30;
// Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30.
if s <= 60 { s - 1 } else { s }
}
DateSystem::D1904 => base - UNIX_1904_01_01,
let first = days_from_civil(y as i32, m as u32, 1);
// Serial of the first of the month; the day (and any overflow) counts on from there.
let first = match sys {
// Up to February 1900 the serial is one lower than the day count from 1899-12-30: the
// fictitious 1900-02-29 (serial 60) comes before 1 March.
DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2),
DateSystem::D1904 => first - UNIX_1904_01_01,
};
let serial = first.checked_add(day)?.checked_sub(1)?;
if !(0..=2_958_465).contains(&serial) {
return None;
}
@@ -97,7 +105,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
/// Date/time from a serial. `None` for negative or too-large serials.
pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if !serial.is_finite() || serial < 0.0 || serial >= 2_958_466.0 {
if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) {
return None;
}
let mut day = serial.floor() as i64;
@@ -168,10 +176,58 @@ mod tests {
assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0));
}
#[test]
fn fictitious_1900_02_29() {
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
// through it.
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0));
assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0));
assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0));
assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0));
assert_eq!(serial_from_ymd(s, 1900, 1, -1), None);
assert_eq!(serial_from_ymd(s, 1900, 0, 1), None);
// Later years keep the real calendar.
assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1));
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
// Every serial up to 1900-12-31 comes back from its own year, month and day.
for serial in 0..=366 {
let d = datetime_from_serial(s, serial as f64).unwrap();
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
}
assert_eq!(days_in_month_in(s, 1900, 2), 29);
assert_eq!(days_in_month_in(s, 1901, 2), 28);
assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28);
// The 1904 system has no fictitious day: 1904 is a real leap year.
let s = DateSystem::D1904;
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0));
assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0));
assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
}
#[test]
fn hostile_month_and_day() {
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None);
assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None);
assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None);
assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None);
assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None);
}
#[test]
fn roundtrip() {
let s = DateSystem::D1900;
for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] {
for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
let d = datetime_from_serial(s, serial).unwrap();
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
}
+1 -1
View File
@@ -10,4 +10,4 @@ pub mod value;
pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
pub use date::DateSystem;
pub use value::{Array, CellError, Value, compare, compare_text, number_to_text, sort_compare};
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
+17 -1
View File
@@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
if !(1..=12).contains(&m) || d < 1 {
return None;
}
let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 };
let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64;
if d > max {
return None;
}
@@ -275,6 +275,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
/// The year used for dates typed without one. Fixed per process from the system clock when
/// available (wasm without clock falls back to 2026).
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
pub fn current_year() -> i64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -357,4 +358,19 @@ mod tests {
assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75));
assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0));
}
#[test]
fn fictitious_1900_02_29() {
// Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61.
assert_eq!(p("2/29/1900").value, Value::Number(60.0));
assert_eq!(p("1900-02-29").value, Value::Number(60.0));
assert_eq!(p("29-Feb-1900").value, Value::Number(60.0));
assert_eq!(p("2/28/1900").value, Value::Number(59.0));
assert_eq!(p("3/1/1900").value, Value::Number(61.0));
assert_eq!(p("2/29/1901").value, Value::text("2/29/1901"));
assert_eq!(p("2/30/1900").value, Value::text("2/30/1900"));
// The 1904 system starts after it.
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
}
}
+31 -3
View File
@@ -288,13 +288,13 @@ impl Value {
pub fn compare(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Empty, Value::Empty) => Ordering::Equal,
(Value::Empty, Value::Number(n)) => 0.0f64.partial_cmp(n).unwrap_or(Ordering::Equal),
(Value::Number(n), Value::Empty) => n.partial_cmp(&0.0).unwrap_or(Ordering::Equal),
(Value::Empty, Value::Number(n)) => compare_numbers(0.0, *n),
(Value::Number(n), Value::Empty) => compare_numbers(*n, 0.0),
(Value::Empty, Value::Text(t)) => "".cmp(&t.to_lowercase() as &str),
(Value::Text(t), Value::Empty) => (t.to_lowercase() as String).as_str().cmp(""),
(Value::Empty, Value::Bool(b)) => false.cmp(b),
(Value::Bool(b), Value::Empty) => b.cmp(&false),
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
(Value::Number(x), Value::Number(y)) => compare_numbers(*x, *y),
(Value::Text(x), Value::Text(y)) => compare_text(x, y),
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
(Value::Error(x), Value::Error(y)) => x.code().cmp(&y.code()),
@@ -302,6 +302,23 @@ pub fn compare(a: &Value, b: &Value) -> Ordering {
}
}
/// Compares numbers the way Excel does: both are rounded to 15 significant digits first, so binary
/// noise such as `0.1+0.2` versus `0.3` counts as equal.
pub fn compare_numbers(a: f64, b: f64) -> Ordering {
fn round15(x: f64) -> f64 {
if !x.is_finite() || x == 0.0 { x } else { format!("{x:.14e}").parse().unwrap_or(x) }
}
if a == b {
return Ordering::Equal;
}
// Numbers further apart than the 15th digit can't round together, and rounding keeps their
// order: skip the text round trip (this is on the sort and lookup hot path).
if (a - b).abs() > a.abs().max(b.abs()) * 1e-13 {
return a.partial_cmp(&b).unwrap_or(Ordering::Equal);
}
round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal)
}
/// Case-insensitive text order (Unicode lowercase).
pub fn compare_text(a: &str, b: &str) -> Ordering {
let mut ai = a.chars().flat_map(char::to_lowercase);
@@ -410,6 +427,17 @@ mod tests {
assert_eq!(CellError::parse("#div/0!"), Some(CellError::Div0));
}
#[test]
fn numbers_compare_at_15_significant_digits() {
let num = Value::Number;
assert_eq!(compare(&num(0.1 + 0.2), &num(0.3)), Ordering::Equal);
assert_eq!(compare(&num(1.1 * 3.0), &num(3.3)), Ordering::Equal);
assert_eq!(compare(&num(1.0), &num(1.0 + 1e-16)), Ordering::Equal);
assert_ne!(compare(&num(0.1 + 0.2), &num(0.3)), Ordering::Greater);
assert_eq!(compare(&num(1.0), &num(1.00000000000001)), Ordering::Less);
assert_eq!(compare(&num(1.0), &num(2.0)), Ordering::Less);
}
#[test]
fn arrays() {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
+3
View File
@@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true }
gridcraft-chart = { workspace = true }
gridcraft-pdf = { workspace = true }
image = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+2
View File
@@ -1,6 +1,8 @@
//! Performance scenarios on large workbooks (release build):
//! `cargo run --release -p gridcraft-engine --example perf -- [rows]`
#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it
use std::time::Instant;
use gridcraft_engine::Session;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Excel's ribbon and menu tree (feature names only), used as the parity metric: a catalog
//! entry counts as done when a command with its id is registered. `cargo xtask parity` writes
//! `docs/parity.md` from this list.
//! `docs/parity-checklist.md` from this list.
/// (id, tab, group, label)
pub const CATALOG: &[(&str, &str, &str, &str)] = &[
+172 -20
View File
@@ -112,7 +112,8 @@ fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
}
let sel = d.selection.current();
if !sel.is_single() {
let used = sh.used_range().unwrap_or(sel);
// An empty sheet has no data to work on: just the active cell.
let Some(used) = sh.used_range() else { return Ok(RangeRef::cell(d.selection.active)) };
return Ok(sel.intersection(&used).unwrap_or(sel));
}
Ok(current_region(sh, d.selection.active))
@@ -265,6 +266,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
let mut pictures = Vec::new();
for (dst_i, &src_i) in order.iter().enumerate() {
for &k in &lanes {
let src = line(src_i, k);
@@ -277,21 +279,52 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64);
c.formula = Some(Arc::new(Formula::from_expr(shifted)));
}
if let Some(picture) = sh.cell_pictures.get(&src) {
pictures.push((dst, picture.clone()));
}
moved.push((dst, cell));
}
}
// Row heights travel with rows.
let heights: Vec<Option<LineInfo>> =
if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() };
// Floating objects anchored inside the sorted body travel with their lines, unless they
// are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering
// is the inverse of `order`: `dst_of[src] = dst`.
let dst_of: Vec<u32> = {
let mut v = vec![0u32; order.len()];
for (dst_i, &src_i) in order.iter().enumerate() {
if let Some(slot) = v.get_mut(src_i as usize) {
*slot = dst_i as u32;
}
}
v
};
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
// can be re-fitted to the lines it spans after the sort.
let objs: Vec<Anchor> =
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
let descs: Vec<(u32, u32, f32, f32)> = objs
.iter()
.map(|a| {
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
let tc = sh.col_at(x2);
let tr = sh.row_at(y2);
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
})
.collect();
let shm = cx.sheet_mut(sheet)?;
for (c, cell) in &moved {
match cell {
Some(x) => shm.cells.set(*c, x.clone()),
Some(x) => shm.set_cell(*c, x.clone()),
None => {
shm.cells.remove(*c);
shm.remove_cell(*c);
}
}
}
shm.cell_pictures.extend(pictures);
for (i, h) in heights.into_iter().enumerate() {
let row = body.start.row + i as u32;
match h {
@@ -305,6 +338,34 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
}
}
}
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
// Only objects anchored inside the sorted block move: within its lines on the sort axis
// and within its span on the other axis (a picture beside the block stays put).
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
let ni = shm.images.len();
let nch = shm.charts.len();
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 {
continue;
}
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
if na != a {
if i < ni {
if let Some(o) = shm.images.get_mut(i) {
o.anchor = na;
}
} else if i < ni + nch {
if let Some(o) = shm.charts.get_mut(i - ni) {
o.anchor = na;
}
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
o.anchor = na;
}
}
}
for (c, _) in moved {
cx.changed.push((sheet, c));
}
@@ -314,6 +375,35 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
Ok(json!({"sorted": body.a1()}))
}
/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the
/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
}
let (tc, tr, tdx, tdy) = bottom;
let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } };
let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) };
if axis_start < b0 || axis_start > b1 {
return a;
}
let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) };
if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 {
let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) };
let x1 = sh.col_left(nc) + a.dx as f64;
let y1 = sh.row_top(nr) + a.dy as f64;
let x2 = sh.col_left(nbc) + tdx as f64;
let y2 = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
} else {
Anchor { cell: CellRef::new(nr, nc), ..a }
}
}
// ---------------------------------------------------------------- filter
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
@@ -579,6 +669,9 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
// Rows and columns past the data are blank: only the part with data is compared (whole
// columns compared a million blank rows and reported them as removed duplicates).
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let cols: Vec<u32> = p
.get("columns")
@@ -600,21 +693,30 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
removed += 1;
}
}
let pictures: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
})
.collect();
let rows: Vec<Vec<Option<Cell>>> =
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
let shm = cx.sheet_mut(sheet)?;
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
shm.cells.remove(CellRef::new(row, c));
shm.remove_cell(CellRef::new(row, c));
}
}
for (i, cells) in rows.into_iter().enumerate() {
for (j, cell) in cells.into_iter().enumerate() {
if let Some(cell) = cell {
shm.cells.set(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
}
}
}
shm.cell_pictures.extend(pictures);
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
cx.changed.push((sheet, c));
}
@@ -662,13 +764,12 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
}
}
for (c, t) in out {
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned();
let cell = super::edit::input_to_cell(&t, old.as_ref(), &mut cx.wb).unwrap_or(None);
let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
let shm = cx.sheet_mut(sheet)?;
match cell {
Some(x) => shm.cells.set(c, x),
Some(x) => shm.set_cell(c, x),
None => {
shm.cells.remove(c);
shm.remove_cell(c);
}
}
cx.changed.push((sheet, c));
@@ -683,9 +784,15 @@ fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char)
let mut cur = String::new();
let mut in_q = false;
let mut last_delim = false;
for c in text.chars() {
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == quote {
in_q = !in_q;
if in_q && chars.peek() == Some(&quote) {
cur.push(quote);
chars.next();
} else {
in_q = !in_q;
}
continue;
}
if !in_q && delims.contains(&c) {
@@ -718,16 +825,42 @@ fn op_param(s: Option<&str>) -> CfOperator {
}
}
/// `r` minus `cut`, as up to four non-overlapping rectangles.
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
let Some(i) = r.intersection(cut) else { return vec![r] };
let mut out = Vec::new();
if i.start.row > r.start.row {
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
}
if i.end.row < r.end.row {
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
}
if i.start.col > r.start.col {
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
}
if i.end.col < r.end.col {
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
}
out
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
}
}
validations.retain(|d| !d.ranges.is_empty());
}
fn validation(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
if bool_param(p, "clear").unwrap_or(false) {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
for dv in sh.validations.iter_mut() {
dv.ranges.retain(|r| !ranges.iter().any(|x| x.contains_range(r)));
}
sh.validations.retain(|d| !d.ranges.is_empty());
remove_validation_cells(&mut sh.validations, &ranges);
Ok(Json::Null)
});
}
@@ -767,10 +900,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
for v in sh.validations.iter_mut() {
v.ranges.retain(|r| !ranges.iter().any(|x| x.contains_range(r)));
}
sh.validations.retain(|d| !d.ranges.is_empty());
remove_validation_cells(&mut sh.validations, &ranges);
sh.validations.push(dv.clone());
Ok(Json::Null)
})
@@ -868,7 +998,7 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
let r = sh.used_range().and_then(|u| r.intersection(&u));
let Some(r) = r else { continue };
for c in r.iter().take(100_000) {
let text = sh.cell(c).map(|x| x.input_text()).unwrap_or_default();
let text = sh.input_text(c);
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
bad_cells.push(c.a1());
}
@@ -1006,6 +1136,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
s.new_workbook();
let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi});
let ok = |s: &mut Session, cell: &str, input: &str| {
s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true)
};
s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap();
s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap();
assert!(ok(&mut s, "B2", "999"));
assert!(!ok(&mut s, "B1", "999"));
assert!(!ok(&mut s, "B3", "999"));
s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap();
for cell in ["B2", "B3", "B4"] {
assert!(ok(&mut s, cell, "150"), "{cell}");
assert!(!ok(&mut s, cell, "5"), "{cell}");
}
assert!(ok(&mut s, "B1", "5"));
assert!(!ok(&mut s, "B1", "150"));
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
+9 -1
View File
@@ -82,7 +82,15 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let shape = Shape {
id,
kind: ShapeKind::Ink,
anchor: Anchor { cell, dx, dy, width: (maxx - minx).max(1.0) as f32, height: (maxy - miny).max(1.0) as f32 },
anchor: Anchor {
cell,
dx,
dy,
width: (maxx - minx).max(1.0) as f32,
height: (maxy - miny).max(1.0) as f32,
// Ink is a freehand mark: pinned where it was drawn, it does not follow cells.
mode: gridcraft_model::AnchorMode::Absolute,
},
fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
text,
+168 -52
View File
@@ -40,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy),
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy),
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut),
cmd!(
"edit.paste",
"Paste",
@@ -119,9 +119,10 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------------------------------------------------------------- entry
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
/// format). Errors when the formula can't be parsed.
pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
let style = old.map(|c| c.style).unwrap_or_default();
/// format). Inherits the destination's cell, row or column style, including its number
/// format, before parsing. Errors when the formula can't be parsed.
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
if input.is_empty() {
let c = Cell { value: Value::Empty, formula: None, style };
return Ok(if c.is_blank() { None } else { Some(c) });
@@ -148,6 +149,7 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_
parsed = gridcraft_formula::parse(&text);
}
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
let expr = wb.name_call_case(expr);
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
// Formulas whose result is a date/time get a format from functions like TODAY().
@@ -206,23 +208,28 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection.
let range = cx.sel.current();
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned();
let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default();
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
if let Some(f) = cell.formula.as_mut() {
std::sync::Arc::make_mut(f).array = Some(range);
}
let sh = cx.sheet_mut(sheet)?;
sh.cells.set(range.start, cell);
// The array takes the whole range: other cells in it lose their contents.
for c in range.iter().skip(1).take(1_000_000) {
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
let style = old.style;
sh.set_cell(c, Cell { style, ..Cell::default() });
}
}
sh.set_cell(range.start, cell);
cx.touch(sheet, range.start);
return Ok(Json::Null);
}
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned();
let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?;
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.cells.set(at, c),
Some(c) => sh.set_cell(at, c),
None => {
sh.cells.remove(at);
sh.remove_cell(at);
}
}
cx.touch(sheet, at);
@@ -265,21 +272,22 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
for (ci, v) in cols.iter().enumerate() {
let Some(at) = start.offset(ri as i64, ci as i64) else { continue };
let input = json_to_input(v);
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned();
let cell = match v {
Json::Number(n) => Some(Cell {
value: Value::number(n.as_f64().unwrap_or(0.0)),
formula: None,
style: old.as_ref().map(|c| c.style).unwrap_or_default(),
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
}),
Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }),
_ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?,
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.cells.set(at, c),
Some(c) => sh.set_cell(at, c),
None => {
sh.cells.remove(at);
sh.remove_cell(at);
}
}
cx.touch(sheet, at);
@@ -297,8 +305,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let input = str_param(p, "input").unwrap_or("").to_string();
let origin = s.doc()?.selection.active;
edit(s, |cx| {
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned();
let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
@@ -312,7 +319,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style);
cx.sheet_mut(sheet)?.cells.set(at, cell);
cx.sheet_mut(sheet)?.set_cell(at, cell);
cx.touch(sheet, at);
}
}
@@ -478,7 +485,8 @@ fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(n) = d.wb.name(reference, sheet) {
n.formula.clone()
} else if let Some((si, ti)) = d.wb.table(reference) {
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.range.a1()).unwrap_or_default();
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
} else {
return Err(EngineError::Other("Reference isn't valid.".into()));
@@ -611,9 +619,16 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
range
};
let text = crate::display::range_text(&d.wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() };
s.clipboard = Some(clip);
Ok(json!({"range": range.a1(), "text": text}))
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
result["html"] = Json::String(html);
}
Ok(result)
}
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
@@ -665,13 +680,12 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned();
let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.cells.set(c, cell),
Some(cell) => sh.set_cell(c, cell),
None => {
sh.cells.remove(c);
sh.remove_cell(c);
}
}
cx.touch(sheet, c);
@@ -784,9 +798,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
};
let mut moved_formulas = Vec::new();
let mut moved_pictures = std::collections::BTreeMap::new();
if cut_move {
// Remove the source first (contents and formats), and fix references to it.
let taken = cx.sheet_mut(clip.sheet)?.cells.take_range(src);
let source = cx.sheet_mut(clip.sheet)?;
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
let taken = source.take_cells(src);
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
@@ -805,16 +822,34 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
src_sheet.cell(sc).cloned()
};
let src_val = src_sheet.value(sc);
if skip_blanks && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
return Some(std::sync::Arc::new(Formula::from_expr(e)));
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
moved.array = f.array;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
@@ -828,10 +863,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
@@ -843,6 +880,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
@@ -851,6 +889,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
@@ -866,10 +905,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
let a = old.value.clone();
let b = new.value.clone();
// Numbers stored as text take part (multiplying by 1 turns them into
// numbers); other text and errors stay as they are.
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
&& (a.is_number() || a.is_empty())
&& (a.is_number() || a.is_empty() || a.is_text())
&& (b.is_number() || b.is_empty())
{
new.value = match op.as_str() {
@@ -883,8 +928,26 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
Value::number(x / y)
}
}
_ => b,
_ => b.clone(),
};
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
new.value = a;
}
// A formula already there is kept and combined with the pasted number.
if let Some(f) = &old.formula
&& let Ok(y) = b.to_number()
&& (b.is_number() || b.is_empty())
&& let Some(sym) = match op.as_str() {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
{
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
}
@@ -903,9 +966,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
cx.sheet_mut(dest_sheet)?.cells.set(dest, new);
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.cells.set(dest, new),
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
@@ -929,11 +995,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
if dest_sheet == clip.sheet {
let e = gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col };
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
}
let _ = dest_name;
let e = if dest_sheet == clip.sheet {
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
} else {
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
cx.structural = true;
}
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
@@ -946,7 +1013,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"pasted": s.doc()?.selection.a1()}))
}
/// Applies a reference adjustment to every formula in the workbook (and names, CF, DV).
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -998,12 +1065,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
for (c, mut cell) in cells {
match what {
"all" => {
sh.cells.remove(c);
sh.remove_cell(c);
}
"contents" => {
cell.value = Value::Empty;
cell.formula = None;
sh.cells.set(c, cell);
sh.set_cell(c, cell);
}
"formats" => {
cell.style = StyleId::DEFAULT;
@@ -1034,6 +1101,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let mut r = target_range(s, p)?;
// Whole rows or columns fill only where the sheet has content (nothing on an empty sheet).
if r.is_full_cols() || r.is_full_rows() {
match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) {
Some(u) => r = u,
None => return Ok(Json::Null),
}
}
if r.count() > 5_000_000 {
return Err(bad("edit.fill", "range too large"));
}
// With one row selected, Fill Down copies from the row above (Excel behaviour).
if dr == 1 && r.height() == 1 {
r.start.row = r.start.row.saturating_sub(1);
@@ -1050,8 +1127,6 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
let used = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range());
let r = if r.is_full_cols() || r.is_full_rows() { used.and_then(|u| r.intersection(&u)).unwrap_or(r) } else { r };
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
Ok(Json::Null)
@@ -1068,8 +1143,8 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"values" => crate::fill::FillMode::ValuesOnly,
_ => crate::fill::FillMode::Series,
};
if target.count() > 5_000_000 {
return Err(bad("edit.autoFill", "target too large"));
if target.count() > 5_000_000 || src.count() > 5_000_000 {
return Err(bad("edit.autoFill", "range too large"));
}
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, target, mode)?;
@@ -1175,7 +1250,11 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
.cells
.iter()
.filter_map(|(c, cell)| {
let text = if values || cell.formula.is_none() { crate::display::cell_text(&d.wb, sh, c) } else { cell.input_text() };
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
crate::display::cell_text(&d.wb, sh, c)
} else {
cell.input_text()
};
matches(&text, &what, case, whole).then_some((c, text))
})
.collect();
@@ -1219,7 +1298,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let mut n = 0;
for si in sheets {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default();
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
for (c, text) in cells {
if !all && c != active {
continue;
@@ -1234,13 +1313,12 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
} else {
replace_ci(&text, &what, &with)
};
let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned();
let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.cells.set(c, cell),
Some(cell) => sh.set_cell(c, cell),
None => {
sh.cells.remove(c);
sh.remove_cell(c);
}
}
cx.touch(si, c);
@@ -1310,6 +1388,22 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap();
s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap();
s.execute("selection.set", json!({"range": "C2:C4"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "C3")["value"], json!(4.0));
// Past the result Excel shows #N/A; the old content of the range is replaced.
assert_eq!(get(&mut s, "C4")["text"], "#N/A");
let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text());
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
}
#[test]
fn wildcards() {
assert!(wildcard("hello", "h*o"));
@@ -1327,6 +1421,28 @@ mod tests {
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
}
#[test]
fn paste_special_operations_keep_text_errors_and_formulas() {
let mut s = Session::new();
s.new_workbook();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}),
)
.unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], "123");
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
s.execute("selection.set", json!({"range": "A1:E1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.0));
assert_eq!(get(&mut s, "B1")["value"], "Total");
assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A");
assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1");
assert_eq!(get(&mut s, "D1")["value"], json!(8.0));
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
+10 -5
View File
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Chart Element",
["Chart Design", "Chart Layouts"],
None,
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles, on?: bool, position?: bottom|top|left|right}",
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth, on?: bool, position?: bottom|top|left|right}",
has_doc,
add_element
),
@@ -291,6 +291,11 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into()));
}
"smooth" => {
for s in &mut c.series {
s.smooth = on;
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())
@@ -468,13 +473,13 @@ fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1()}));
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1()}));
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.charts {
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1()}));
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
@@ -840,7 +845,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(si)?;
let v = sh.value(c);
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
sh.cells.set(c, Cell { value, formula: None, style: new });
sh.set_cell(c, Cell { value, formula: None, style: new });
cx.touch(si, c);
}
}
+17 -3
View File
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string();
let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true);
let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination.
let path = str_param(p, "path").filter(|_| !imported).map(str::to_string);
// Replace an untouched blank Book1 like Excel does.
if s.documents().len() == 1
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
@@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
s.close_document(0);
}
let i = s.add_document(DocState::new(wb, path, title));
if !warnings.is_empty() {
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
if warnings.len() > 5 {
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
}
s.ui_requests.push(crate::UiRequest::Message(message));
}
let d = s.doc()?;
Ok(
json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
+27 -2
View File
@@ -365,6 +365,9 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
let base = border_style(str_param(p, "style").unwrap_or("thin"));
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
for r in &ranges {
if r.count() > 2_000_000 {
@@ -453,7 +456,29 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
}
});
cx.sheet_mut(sheet)?.set_style(c, new);
// Clear the neighbour's facing edge when removing borders, so it really goes.
if preset == "none" {
// A shared line can be stored on either cell. Selected cells are handled
// by this loop; remove only facing edges outside this range.
for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] {
if !boundary {
continue;
}
let Some(neighbor) = c.offset(dr, dc) else { continue };
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default();
let new = cx.wb.styles.derive(st, |s| {
let edge = match (dr, dc) {
(-1, _) => &mut s.borders.bottom,
(1, _) => &mut s.borders.top,
(_, -1) => &mut s.borders.right,
_ => &mut s.borders.left,
};
*edge = BorderLine::default();
});
if new != st {
cx.sheet_mut(sheet)?.set_style(neighbor, new);
}
}
}
}
}
Ok(Json::Null)
@@ -560,7 +585,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
sh.cells.set(c, cell);
sh.set_cell(c, cell);
}
touched.push((sheet, c));
}
+50 -21
View File
@@ -36,14 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
define_name
),
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?}", has_doc, delete_name),
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
cmd!(
"formulas.createFromSelection",
"Create from Selection",
["Formulas", "Defined Names"],
Some("Cmd+Shift+F3"),
"{range?, top?: true, left?: false, bottom?, right?}",
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
has_doc,
create_from_selection
),
@@ -218,15 +218,26 @@ fn valid_name(n: &str) -> bool {
if !(first.is_alphabetic() || first == '_' || first == '\\') {
return false;
}
if n.len() > 255 || n.eq_ignore_ascii_case("R") || n.eq_ignore_ascii_case("C") {
return false;
}
if CellRef::parse(n).is_some() {
if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
return false;
}
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
}
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
/// accept as a name.
fn is_r1c1(n: &str) -> bool {
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
let mut rest = n;
if let Some(r) = rest.strip_prefix(['R', 'r']) {
rest = r.get(digits(r)..).unwrap_or("");
}
if let Some(c) = rest.strip_prefix(['C', 'c']) {
rest = c.get(digits(c)..).unwrap_or("");
}
rest.is_empty() && !n.is_empty()
}
fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
let Some(name) = str_param(p, "name").map(str::to_string) else {
s.ui_requests.push(crate::UiRequest::Dialog("defineName".into(), json!({})));
@@ -264,9 +275,14 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
let scope = match str_param(p, "scope") {
None => None,
Some("Workbook") => Some(None),
Some(sh) => Some(Some(s.doc()?.wb.sheet_index(sh).ok_or_else(|| bad("formulas.deleteName", "no such sheet"))?)),
};
edit(s, |cx| {
let before = cx.wb.names.len();
cx.wb.names.retain(|n| !n.name.eq_ignore_ascii_case(&name));
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && scope.is_none_or(|sc| n.scope == sc)));
if cx.wb.names.len() == before {
return Err(bad("formulas.deleteName", "no such name"));
}
@@ -295,6 +311,9 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let top = bool_param(p, "top").unwrap_or(true);
let left = bool_param(p, "left").unwrap_or(false);
let bottom = bool_param(p, "bottom").unwrap_or(false);
let right = bool_param(p, "right").unwrap_or(false);
let replace = bool_param(p, "replace").unwrap_or(false);
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let q = gridcraft_formula::quote_sheet(&sh.name);
@@ -309,28 +328,38 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
};
let clean = |t: String| -> String {
let mut n: String = t.trim().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect();
if n.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&n).is_some() {
if !n.is_empty() && !valid_name(&n) {
n.insert(0, '_');
}
n
if valid_name(&n) { n } else { String::new() }
};
// The labels in the chosen edge rows/columns name the cells between them.
let (r0, r1) = (r.start.row + top as u32, r.end.row.saturating_sub(bottom as u32));
let (c0, c1) = (r.start.col + left as u32, r.end.col.saturating_sub(right as u32));
let mut made = Vec::new();
if top && r.height() > 1 {
for c in r.start.col..=r.end.col {
let name = clean(sh.value(CellRef::new(r.start.row, c)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(r.start.row + 1, c), CellRef::new(r.end.row, c)))));
if r0 <= r1 && c0 <= c1 {
for (on, row) in [(top, r.start.row), (bottom, r.end.row)] {
for c in (c0..=c1).filter(|_| on) {
let name = clean(sh.value(CellRef::new(row, c)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c)))));
}
}
}
}
if left && r.width() > 1 {
for row in r.start.row + top as u32..=r.end.row {
let name = clean(sh.value(CellRef::new(row, r.start.col)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(row, r.start.col + 1), CellRef::new(row, r.end.col)))));
for (on, col) in [(left, r.start.col), (right, r.end.col)] {
for row in (r0..=r1).filter(|_| on) {
let name = clean(sh.value(CellRef::new(row, col)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1)))));
}
}
}
}
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
// reported.
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
let n = made.len();
edit(s, |cx| {
for (name, f) in made {
@@ -338,7 +367,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
cx.wb.names.push(DefinedName { name, scope: None, formula: f, comment: String::new(), hidden: false });
}
cx.structural = true;
Ok(json!({"created": n}))
Ok(json!({"created": n, "skipped": skipped}))
})
}
+189 -15
View File
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Picture",
["Insert", "Illustrations"],
None,
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?}",
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, mode?: moveOnly|moveAndSize|absolute, placement?: cell|overCells}",
has_doc,
insert_picture
),
@@ -122,8 +122,18 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&with(p, "kind", json!("textBox"))
)),
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object),
cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object),
cmd!(
"object.setAnchorMode",
"Object Properties",
[],
None,
"{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
cmd!(
"insert.link",
"Link",
@@ -220,6 +230,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
} else {
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
.into_iter()
.filter_map(|(c, v)| {
let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) };
dest.map(|c| (c, v))
})
.collect();
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
}
@@ -228,17 +245,24 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
};
let sh = cx.sheet_mut(sheet)?;
let mut names: Vec<String> = Vec::new();
// Lower-case names taken, and the next suffix for each header, so that thousands of equal
// headers don't take cubic time.
let mut taken = std::collections::HashSet::new();
let mut next_suffix = std::collections::HashMap::new();
for c in r.start.col..=r.end.col {
let mut n = sh.value(CellRef::new(r.start.row, c)).display();
if n.is_empty() {
n = format!("Column{}", c - r.start.col + 1);
}
let base = n.clone();
let mut k = 2;
while names.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
let key = base.to_ascii_lowercase();
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
while taken.contains(&n.to_ascii_lowercase()) {
n = format!("{base}{k}");
k += 1;
}
next_suffix.insert(key, k);
taken.insert(n.to_ascii_lowercase());
// Headers are text.
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str()));
names.push(n);
@@ -304,7 +328,7 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
t.range.end.row = t.range.end.row.saturating_sub(1);
let (c0, c1) = (t.range.start.col, t.range.end.col);
for c in c0..=c1 {
sh.cells.remove(CellRef::new(row, c));
sh.remove_cell(CellRef::new(row, c));
}
}
write_totals(sh, ti);
@@ -333,12 +357,11 @@ fn write_totals(sh: &mut Sheet, ti: usize) {
let c = CellRef::new(row, t.range.start.col + i as u32);
if let Some(code) = col.totals.subtotal_code() {
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
sh.cells
.set(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
} else if let Some(l) = &col.totals_label {
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
} else {
sh.cells.remove(c);
sh.remove_cell(c);
}
}
}
@@ -508,6 +531,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
smooth: false,
});
}
} else {
@@ -521,15 +545,19 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
smooth: false,
});
}
}
out
}
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let r = block(s, p)?;
if r.count() > 1_000_000 {
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let sheet = target_sheet(s, p)?;
@@ -566,6 +594,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
@@ -654,14 +683,25 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
let Some(src) = c.source.clone() else { return Ok(Json::Null) };
// Charts read from a file have no source range: take the block their series span.
let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) };
let (sheet_name, body) = split_sheet(&src);
let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
let Some(r) = RangeRef::parse(body) else { return Ok(Json::Null) };
let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) };
let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
let by_rows = !c.by_rows;
// Whole-column/row references (common in files) span a million cells: clip the block's
// far edges to the cells in use, then refuse anything still too large to chart.
if let Some(used) = ssh.used_range() {
r.end.row = r.end.row.min(used.end.row).max(r.start.row);
r.end.col = r.end.col.min(used.end.col).max(r.start.col);
}
if r.count() > MAX_CHART_CELLS {
return Err(bad("chart.switchRowColumn", "range too large for a chart"));
}
let by_rows = !by_rows;
let series = series_from_range(ssh, r, by_rows);
if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
c.source.get_or_insert(src);
c.by_rows = by_rows;
c.series = series;
}
@@ -669,6 +709,29 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The block a chart's series come from, and whether each series is a row of it: the bounding
/// range of the series names, categories and values, when they're all references to one sheet.
fn series_block(series: &[Series]) -> Option<(String, bool)> {
let mut sheet: Option<String> = None;
let mut bounds: Option<RangeRef> = None;
let mut rows = 0;
for s in series {
for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() {
let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None };
let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None };
if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) {
return None;
}
let range = r.range();
if i == 0 && range.height() == 1 && range.width() > 1 {
rows += 1;
}
bounds = Some(bounds.map_or(range, |b| b.union(&range)));
}
}
Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len()))
}
fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
@@ -696,14 +759,39 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
}
fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let placement = str_param(p, "placement").unwrap_or("overCells");
if !matches!(placement, "cell" | "overCells") {
return Err(bad("insert.picture", "placement must be cell or overCells"));
}
let data = if let Some(b) = str_param(p, "base64") {
crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({})));
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement})));
return ok();
};
if placement == "cell" {
let mime = validate_cell_picture(&data)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let at = match str_param(p, "at") {
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
None => d.selection.active,
};
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
let style = sh.style_id(at);
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
}
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
return edit(s, |cx| {
cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture));
cx.touch(sheet, at);
Ok(json!({"cell": at.a1(), "placement": "cell"}))
});
}
if data.len() > 64 * 1024 * 1024 {
return Err(bad("insert.picture", "image larger than 64 MB"));
}
@@ -723,6 +811,8 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
dy: 0.0,
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
},
data,
mime,
@@ -735,6 +825,33 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode.
fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
if data.len() > 16 * 1024 * 1024 {
return Err(bad("insert.picture", "cell pictures must be at most 16 MiB"));
}
let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?;
let mime = match format {
image::ImageFormat::Png => "image/png",
image::ImageFormat::Jpeg => "image/jpeg",
_ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")),
};
let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format)
.into_dimensions()
.map_err(|_| bad("insert.picture", "invalid image dimensions"))?;
if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 {
return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels"));
}
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?;
Ok(mime)
}
fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
@@ -764,6 +881,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
dy: 0.0,
width: f64_param(p, "width").unwrap_or(144.0) as f32,
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
fill,
line,
@@ -776,6 +894,37 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// A text box holds at most as many characters as a cell.
const SHAPE_TEXT_MAX_CHARS: usize = 32_767;
fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
let id =
p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?;
let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?;
if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() {
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
if shape.kind != ShapeKind::TextBox {
return Err(bad("shape.setText", "the shape is not a text box"));
}
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
}
if shape.text == text {
return ok();
}
edit(s, |cx| {
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
shape.text = text.into();
Ok(Json::Null)
})
}
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
@@ -800,7 +949,8 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
if let Some(at) = cell_param(p, "at") {
@@ -822,6 +972,30 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
/// when the sheet is sorted or edited structurally.
fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
}
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
};
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
a.mode = mode;
Ok(json!({"mode": anchor_mode_name(mode)}))
})
}
// ---------------------------------------------------------------- links & comments
fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
@@ -846,7 +1020,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
});
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
sh.cells.set(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
} else {
sh.set_style(at, link_style);
}
@@ -1134,7 +1308,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let shape = Shape {
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size },
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
fill: color,
line: Color::Auto,
text: name,
+3 -2
View File
@@ -125,10 +125,11 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({
"cell": c.a1(),
"value": value_json(&sh.value(c)),
"type": match sh.value(c) { Value::Empty => "empty", Value::Number(_) => "number", Value::Text(_) => "text", Value::Bool(_) => "boolean", Value::Error(_) => "error", Value::Array(_) => "array" },
"type": if sh.cell_pictures.contains_key(&c) { "picture" } else { match sh.value(c) { Value::Empty => "empty", Value::Number(_) => "number", Value::Text(_) => "text", Value::Bool(_) => "boolean", Value::Error(_) => "error", Value::Array(_) => "array" } },
"formula": cell.and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text)),
"text": crate::display::cell_text(&d.wb, sh, c),
"input": cell.map(|x| x.input_text()).unwrap_or_default(),
"input": sh.input_text(c),
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
"style": style,
+81 -8
View File
@@ -146,6 +146,30 @@ pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32)
}
/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`,
/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive).
pub(crate) fn anchor_mode_param(p: &Json) -> Option<gridcraft_model::AnchorMode> {
parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
}
pub(crate) fn parse_anchor_mode(s: &str) -> Option<gridcraft_model::AnchorMode> {
match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() {
"moveandsize" | "moveandsizeandcells" | "twocell" => Some(gridcraft_model::AnchorMode::MoveAndSize),
"moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(gridcraft_model::AnchorMode::MoveOnly),
"absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(gridcraft_model::AnchorMode::Absolute),
_ => None,
}
}
/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]).
pub(crate) fn anchor_mode_name(m: gridcraft_model::AnchorMode) -> &'static str {
match m {
gridcraft_model::AnchorMode::MoveAndSize => "moveAndSize",
gridcraft_model::AnchorMode::MoveOnly => "moveOnly",
gridcraft_model::AnchorMode::Absolute => "absolute",
}
}
pub(crate) fn ok() -> Result<Json> {
Ok(Json::Null)
}
@@ -209,24 +233,36 @@ pub struct Ctx<'a> {
pub sel: &'a mut Selection,
/// Rows whose automatic height should be recomputed (sheet, row).
pub fit_rows: Vec<(usize, u32)>,
/// The command applied sheet protection itself (row/column inserts and deletes the
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
pub protection_checked: bool,
}
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
let d = s.doc_mut()?;
commit(d, f)
commit(d, |cx| {
let r = f(cx)?;
crate::tables::sync_headers(cx);
Ok(r)
})
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some();
let t0 = std::time::Instant::now();
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
#[allow(clippy::disallowed_methods)]
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new() };
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, .. } = ctx;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb)?;
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
if prof {
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
}
if structural {
@@ -234,7 +270,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
} else if !changed.is_empty() {
d.calc.cells_changed(&mut wb, &changed);
}
if prof {
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", t0.elapsed());
}
fit_rows.sort_unstable();
@@ -242,7 +278,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
for (si, row) in fit_rows {
auto_row_height(&mut wb, si, row);
}
if prof {
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", t0.elapsed());
}
d.wb = std::sync::Arc::new(wb);
@@ -250,6 +286,43 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
Ok(r)
}
/// Excel's message for an edit of a locked cell on a protected sheet.
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
continue;
}
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() {
continue;
}
for c in sh.cells.diff(&after.cells) {
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
return Err(EngineError::Other(PROTECTED.into()));
}
}
}
Ok(())
}
/// Two cells hold the same value or formula (formats aside).
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
match (a, b) {
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
(None, None) => a.value == b.value,
_ => false,
},
_ => empty(a) && empty(b),
}
}
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
/// multi-line text. Rows with a user-set height keep it.
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
+23
View File
@@ -1621,6 +1621,29 @@ mod tests {
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(72.0));
}
#[test]
fn repeated_headers_get_numbered_names() {
let mut s = Session::new();
s.new_workbook();
let mut header = vec![json!("a"), json!("A"), json!("a"), json!("a2")];
// Thousands of equal headers (a row of check boxes, say) used to take cubic time; this would
// hang rather than fail a wall-clock check, which is flaky on a loaded machine.
header.extend((0..3000).map(|_| json!("x")));
let data: Vec<_> = (0..header.len()).map(|i| json!(i)).collect();
let end = CellRef::new(1, header.len() as u32 - 1).a1();
s.execute("range.setValues", json!({"range": "A1", "values": [header, data]})).unwrap();
let src = crate::pivot::read_source(&s.doc().unwrap().wb, &format!("Sheet1!A1:{end}"), 0).unwrap();
assert_eq!(&src.headers[..4], ["a", "A2", "a3", "a22"]);
assert_eq!(src.headers[4], "x");
assert_eq!(src.headers.last().map(String::as_str), Some("x3000"));
// Table column names follow the same rule.
s.execute("insert.table", json!({"range": format!("A1:{end}"), "header": true})).unwrap();
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["text"].clone();
assert_eq!(text(&mut s, "B1"), "A2");
let last = CellRef::new(0, 3003).a1();
assert_eq!(text(&mut s, &last), "x3000");
}
#[test]
fn hostile_params_never_panic() {
let mut s = sales();
+75 -8
View File
@@ -847,11 +847,41 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
if w <= 0.0 || h <= 0.0 {
continue;
}
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else {
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
}
}
pg.restore();
}
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
let (x, y, w, h) = rect;
let pad = 2.0 * k;
let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0));
if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 {
return;
}
let Some((iw, ih)) = crate::io::image_size(data) else { return };
if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 {
return;
}
let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return };
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let Ok(image) = reader.decode() else { return };
let image =
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
let fit = (w / iw as f32).min(h / ih as f32);
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
}
/// Is a cell free for text to overflow into?
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
@@ -923,7 +953,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
};
let lines: Vec<String> = if wrap { wrap_lines(&text, font, size, avail.max(size)) } else { vec![text] };
let rot = st.align.rotation;
let rotated = rot != 0 && rot != 255;
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
let lines: Vec<String> = match (wrap, rotated) {
(true, false) => wrap_lines(&text, font, size, avail.max(size)),
(true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)),
(true, true) => vec![text.lines().next().unwrap_or_default().to_string()],
(false, _) => vec![text],
};
let line_h = size * 1.2;
let n = lines.len().max(1) as f32;
let first_baseline = match st.align.v {
@@ -933,7 +971,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
};
// Overflow into empty neighbours (single-line text, not merged).
let (mut clip_l, mut clip_r) = (x, x + w);
if !wrap && !merged && !is_num && tw + 2.0 * pad + indent > w {
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
let need = tw + 2.0 * pad + indent - w;
let grow = |dir: i64, mut need: f32| -> f32 {
let mut ext = 0.0;
@@ -966,13 +1004,20 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
let clip_r = clip_r.min(limits.1);
pg.save();
pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h);
let rot = st.align.rotation;
if rot != 0 && rot != 255 && !wrap {
if rotated {
// Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell.
// Wrapped lines stack rightward for upward text and leftward for downward text, so
// downward text starts its first line far enough in for the last to end at the left pad.
let deg = (rot as f32).clamp(-90.0, 90.0);
let angle = -deg.to_radians();
let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin(), y + h - pad) } else { (x + pad, y + pad + size * 0.8) };
if let Some(t) = lines.first() {
let last = lines.len().saturating_sub(1) as f32;
for (i, t) in lines.iter().enumerate() {
let i = i as f32;
let (ox, oy) = if deg > 0.0 {
(x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad)
} else {
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
};
pg.text_rotated(ox, oy, size, font, color, t, angle);
}
pg.restore();
@@ -1673,7 +1718,7 @@ mod tests {
let id = wb.next_object_id();
wb.sheet_mut(0).unwrap().images.push(Image {
id,
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0 },
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
data: png,
mime: "image/png".into(),
alt: String::new(),
@@ -1762,4 +1807,26 @@ mod tests {
let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0);
assert!(w.len() > 2);
}
#[test]
fn rotated_wrapped_text_prints_rotated() {
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
let mut s = s();
let header = "Term 1 : Drama : Continuous Assessment";
s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap();
s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap();
s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap();
s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap();
s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap();
s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
let pdf = String::from_utf8_lossy(&pdf);
// The page holds only these two cells, so every text block on it is a piece of a header.
let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect();
let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count();
let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count();
// Every piece of both headers is rotated, each wrapped along the row height into a few long lines.
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
}
}
+235 -52
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{Sheet, Visibility};
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
use serde_json::{Value as Json, json};
use super::*;
@@ -61,6 +61,46 @@ fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
Ok((a, count))
}
/// Bottom-right line of an anchor (the `to` marker of a two-cell anchor) and the offset of that
/// corner inside it, in points. Measured against `sh`'s current geometry.
fn anchor_corner(sh: &Sheet, a: &Anchor) -> (u32, u32, f32, f32) {
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
let tc = sh.col_at(x2);
let tr = sh.row_at(y2);
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
}
/// Repositions one floating object's anchor for a structural insert/delete, honouring its
/// [`AnchorMode`]: absolute objects ("Don't move or size with cells") stay put; "move but don't
/// size" follows its cell and keeps its size; "move and size" is re-fitted to the lines it lands
/// on. `corner` is the pre-edit bottom-right line/offset (from [`anchor_corner`]); a line deleted
/// out from under an edge collapses that edge onto `at`. `sh` is the post-edit grid, used to
/// measure the re-fitted size.
fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis, at: u32, map_line: &impl Fn(u32) -> Option<u32>) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
}
let main = if axis == Axis::Rows { a.cell.row } else { a.cell.col };
// A deleted anchor line collapses onto the line that takes its place.
let nmain = map_line(main).unwrap_or(at);
let (nr, nc) = if axis == Axis::Rows { (nmain, a.cell.col) } else { (a.cell.row, nmain) };
if a.mode == AnchorMode::MoveOnly {
return Anchor { cell: CellRef::new(nr, nc), ..a };
}
// Move and size: re-fit to the lines the object lands on. A far edge whose line was deleted
// collapses onto the near cell, shrinking the object.
let (tc, tr, tdx, tdy) = corner;
let far = if axis == Axis::Rows { tr } else { tc };
let nfar = map_line(far).unwrap_or(nmain);
let (nbr, nbc) = if axis == Axis::Rows { (nfar, tc) } else { (tr, nfar) };
let x1 = sh.col_left(nc) + a.dx as f64;
let y1 = sh.row_top(nr) + a.dy as f64;
let xn = sh.col_left(nbc) + tdx as f64;
let yn = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (xn - x1).max(0.0) as f32, height: (yn - y1).max(0.0) as f32, mode: a.mode }
}
/// Shifts everything that has a position on the sheet for an insert/delete.
fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: bool) {
let map_line = |v: u32| -> Option<u32> {
@@ -74,26 +114,24 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
None
}
};
let map_range = |r: RangeRef| -> Option<RangeRef> {
let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
sheet: gridcraft_formula::SheetSel::Current,
kind: gridcraft_formula::RefKind::Range(
gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
),
});
match gridcraft_formula::adjust::adjust(expr, "S", "S", &e) {
gridcraft_formula::Expr::Ref(r) => Some(r.range()),
_ => None,
}
};
let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
let map_range = |r: RangeRef| map_range(r, &e);
let map_cell = |c: CellRef| -> Option<CellRef> {
match axis {
Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)),
Axis::Cols => map_line(c.col).map(|x| CellRef::new(c.row, x)),
}
};
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
// re-fit after the shift measures against the grid the object actually lands on.
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
.charts
.iter()
.map(|o| &o.anchor)
.chain(sh.images.iter().map(|o| &o.anchor))
.chain(sh.shapes.iter().map(|o| &o.anchor))
.map(|a| anchor_corner(sh, a))
.collect();
// Line info.
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
let old = std::mem::take(lines);
@@ -149,17 +187,26 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
None => sh.autofilter = None,
}
}
sh.cell_pictures =
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
for ch in sh.charts.iter_mut() {
if let Some(c) = map_cell(ch.anchor.cell) {
ch.anchor.cell = c;
}
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
// against the post-shift grid first (the re-fit needs its sizes), then written back.
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
let base = new_charts.len() + new_images.len();
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
o.anchor = a;
}
for im in sh.images.iter_mut() {
if let Some(c) = map_cell(im.anchor.cell) {
im.anchor.cell = c;
}
for (o, a) in sh.images.iter_mut().zip(new_images) {
o.anchor = a;
}
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
o.anchor = a;
}
for sp in sh.sparklines.iter_mut() {
if let Some(c) = map_cell(sp.cell) {
@@ -196,8 +243,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
}
}
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
/// Excel, it cancels copy/cut mode for this workbook (a later paste would take the wrong cells).
fn cancel_copy_mode(s: &mut Session) {
let uid = s.doc().map(|d| d.uid).ok();
if s.clipboard.as_ref().is_some_and(|c| Some(c.doc_uid) == uid) {
s.clipboard = None;
}
}
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?;
// Refuse to push data off the sheet.
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
@@ -207,8 +264,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
edit(s, |cx| {
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true;
{
let sh = cx.sheet_mut(sheet)?;
match axis {
@@ -230,21 +288,25 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})
})?;
cancel_copy_mode(s);
Ok(out)
}
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
check_lines_protection(s, p, axis, at, count, false)?;
let sheet = target_sheet(s, p)?;
edit(s, |cx| {
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true;
{
let sh = cx.sheet_mut(sheet)?;
let block = match axis {
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
};
sh.cells.take_range(block);
sh.take_cells(block);
match axis {
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
@@ -254,7 +316,9 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})
})?;
cancel_copy_mode(s);
Ok(out)
}
fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
@@ -265,27 +329,24 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
"column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
_ => {}
}
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
let sheet = target_sheet(s, p)?;
let right = shift == "right";
edit(s, |cx| {
let out = edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if right {
sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.start.col, r.width() as i64);
} else {
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64);
}
// Formulas referring to cells that moved: approximate with a whole-sheet move edit.
let moved = if right {
RangeRef::new(r.start, CellRef::new(r.end.row, MAX_COLS - 1 - r.width()))
} else {
RangeRef::new(r.start, CellRef::new(MAX_ROWS - 1 - r.height(), r.end.col))
};
shift_cell_features(sh, &e);
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
let to = if right { (r.start.row, r.start.col + r.width()) } else { (r.start.row + r.height(), r.start.col) };
super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 });
rewrite(&mut cx.wb, &name, &e);
cx.structural = true;
Ok(Json::Null)
})
})?;
cancel_copy_mode(s);
Ok(out)
}
fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
@@ -296,31 +357,28 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
"column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
_ => {}
}
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
let sheet = target_sheet(s, p)?;
let left = shift == "left";
edit(s, |cx| {
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.take_range(r);
sh.take_cells(r);
if left {
sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.end.col + 1, -(r.width() as i64));
} else {
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64));
}
shift_cell_features(sh, &e);
}
// References into the deleted block become #REF!; the rest move.
let deleted = Edit::Move { from: r, to_row: r.start.row, to_col: r.start.col };
let _ = deleted;
let moved_from = if left {
RangeRef::new(CellRef::new(r.start.row, r.end.col + 1), CellRef::new(r.end.row, MAX_COLS - 1))
} else {
RangeRef::new(CellRef::new(r.end.row + 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
};
super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col });
// References into the deleted cells become #REF!; the rest move.
rewrite(&mut cx.wb, &name, &e);
cx.structural = true;
Ok(Json::Null)
})
})?;
cancel_copy_mode(s);
Ok(out)
}
fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
@@ -359,6 +417,39 @@ fn edit_doc<R>(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -
super::commit(d, f)
}
/// Sheet protection for inserting or deleting rows/columns: the protection must allow it, and
/// deleted lines must not hold locked cells.
fn check_lines_protection(s: &Session, p: &Json, axis: Axis, at: u32, count: u32, insert: bool) -> Result<()> {
let sheet = target_sheet(s, p)?;
let wb = &s.doc()?.wb;
let Some(sh) = wb.sheet(sheet) else { return Ok(()) };
let Some(pr) = &sh.protection else { return Ok(()) };
let allowed = match (axis, insert) {
(Axis::Rows, true) => pr.insert_rows,
(Axis::Rows, false) => pr.delete_rows,
(Axis::Cols, true) => pr.insert_columns,
(Axis::Cols, false) => pr.delete_columns,
};
let locked = |st: gridcraft_model::StyleId| wb.styles.get(st).protection.locked;
let refuse = || EngineError::Other(PROTECTED.into());
if !allowed {
return Err(refuse());
}
if insert {
return Ok(());
}
let end = at.saturating_add(count - 1).min(if axis == Axis::Rows { MAX_ROWS - 1 } else { MAX_COLS - 1 });
let infos = if axis == Axis::Rows { &sh.rows } else { &sh.cols };
if (at..=end).any(|i| locked(infos.get(&i).and_then(|x| x.style).unwrap_or_default())) {
return Err(refuse());
}
let block = if axis == Axis::Rows { RangeRef::rows(at, end) } else { RangeRef::cols(at, end) };
if sh.cells.iter_range(block).any(|(_, cell)| locked(cell.style)) {
return Err(refuse());
}
Ok(())
}
fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let i = target_sheet(s, p)?;
let d = s.doc()?;
@@ -369,6 +460,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into()));
}
let name = d.wb.sheet(i).map(|s| s.name.clone()).unwrap_or_default();
let order: Vec<String> = d.wb.sheets.iter().map(|s| s.name.clone()).collect();
let d = s.doc_mut()?;
edit_doc(d, |cx| {
cx.wb.sheets.remove(i);
@@ -378,7 +470,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
for (c, f) in keys {
if let Some(e) = f.expr() {
let ne = gridcraft_formula::adjust::delete_sheet(e, &name);
let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order);
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
}
@@ -392,6 +484,13 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
{
*sc -= 1;
}
// Names referring to the sheet become #REF!.
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order);
if ne != e {
n.formula = gridcraft_formula::print(&ne);
}
}
}
if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i {
cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1));
@@ -461,10 +560,31 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if copy {
let mut sh = (*src).clone();
let base = sh.name.clone();
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base);
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
sh.name = name.chars().take(31).collect();
sh.tables.clear(); // table names must be unique
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
.wb
.names
.iter()
.filter(|nm| nm.scope == Some(i))
.map(|nm| {
let formula = gridcraft_formula::parse(&nm.formula)
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name)))
.unwrap_or_else(|_| nm.formula.clone());
gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() }
})
.collect();
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
cx.wb.names.extend(copies);
cx.wb.sheets.insert(at, Arc::new(sh));
cx.wb.active_sheet = at;
} else {
@@ -472,6 +592,17 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let at = to.min(n - 1);
cx.wb.sheets.insert(at, sh);
cx.wb.active_sheet = at;
// Sheet-level names follow their sheet.
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut() {
*sc = match *sc {
x if x == i => at,
x if i < x && x <= at => x - 1,
x if at <= x && x < i => x + 1,
x => x,
};
}
}
}
cx.structural = true;
Ok(Json::Null)
@@ -538,3 +669,55 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
s.doc_mut()?.wb_switch_sheet(i as usize);
ok()
}
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
sheet: gridcraft_formula::SheetSel::Current,
kind: gridcraft_formula::RefKind::Range(
gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
),
});
match gridcraft_formula::adjust::adjust(expr, "S", "S", e) {
gridcraft_formula::Expr::Ref(r) => Some(r.range()),
_ => None,
}
}
/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
for dv in sh.validations.iter_mut() {
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
sh.validations.retain(|dv| !dv.ranges.is_empty());
for t in sh.tables.iter_mut() {
t.range = map_range(t.range, e).unwrap_or_default();
}
sh.tables.retain(|t| t.range != RangeRef::default());
if let Some(af) = sh.autofilter.as_mut() {
match map_range(af.range, e) {
Some(r) => af.range = r,
None => sh.autofilter = None,
}
}
sh.cell_pictures =
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
for sp in sh.sparklines.iter_mut() {
if let Some(c) = map_cell(sp.cell) {
sp.cell = c;
}
}
if let Some(pa) = sh.print.print_area {
sh.print.print_area = map_range(pa, e);
}
}
+11 -12
View File
@@ -201,7 +201,7 @@ impl Trial {
for (si, c, v) in sets {
if let Some(sh) = wb.sheet_mut(*si) {
let style = sh.style_id(*c);
sh.cells.set(*c, Cell { value: v.clone(), formula: None, style });
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
keys.push((*si, *c));
}
}
@@ -330,7 +330,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let sh = cx.sheet_mut(csi)?;
let style = sh.style_id(chg);
sh.cells.set(chg, Cell { value: Value::number(r.x), formula: None, style });
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
cx.touch(csi, chg);
Ok(())
})?;
@@ -489,13 +489,12 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
for (c, v) in cells.iter().zip(sc.values.iter()) {
let input = super::edit::json_to_input(v);
let old = cx.wb.sheet(si).and_then(|sh| sh.cell(*c)).cloned();
let cell = super::edit::input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.cells.set(*c, cell),
Some(cell) => sh.set_cell(*c, cell),
None => {
sh.cells.remove(*c);
sh.remove_cell(*c);
}
}
cx.touch(si, *c);
@@ -572,7 +571,7 @@ fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
let mut sheet = Sheet::new(name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.cells.set(CellRef::new(row, col), Cell { value: v, formula: None, style });
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
};
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
@@ -695,7 +694,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
for (c, v) in out {
let sh = cx.sheet_mut(si)?;
let style = sh.style_id(c);
sh.cells.set(c, Cell { value: v, formula: None, style });
sh.set_cell(c, Cell { value: v, formula: None, style });
cx.touch(si, c);
}
Ok(())
@@ -891,10 +890,10 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
for c in &old {
shm.cells.remove(*c);
shm.remove_cell(*c);
}
for (c, cell) in cells {
shm.cells.set(c, cell);
shm.set_cell(c, cell);
}
for c in old {
cx.touch(dsi, c);
@@ -1108,9 +1107,9 @@ fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(dsi)?;
let style = sh.style_id(c);
if v.is_empty() && style == StyleId::DEFAULT {
sh.cells.remove(c);
sh.remove_cell(c);
} else {
sh.cells.set(c, Cell { value: v, formula: None, style });
sh.set_cell(c, Cell { value: v, formula: None, style });
}
cx.touch(dsi, c);
}
+23 -1
View File
@@ -31,11 +31,28 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
format_value(v, &parsed(code), wb.date_system)
}
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
/// protected sheet. A spilled cell follows its anchor.
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
if !sheet.is_protected() {
return false;
}
let at = match sheet.cell(c) {
Some(x) if x.formula.is_some() => c,
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
Some((anchor, _)) => *anchor,
None => return false,
},
};
wb.styles.get(sheet.style_id(at)).protection.hidden
}
/// The text a cell shows (full precision General; the grid narrows General to fit the column).
pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let v = sheet.value(c);
if sheet.show_formulas
&& let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
&& !formula_hidden(wb, sheet, c)
{
return format!("={}", f.text);
}
@@ -57,7 +74,7 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
let Some(sh) = wb.sheet(sheet) else { return String::new() };
let mut out = String::new();
let r = if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r };
let r = clipboard_range(r);
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
@@ -82,6 +99,11 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
out
}
/// The existing plain-text clipboard limit, shared with its HTML representation.
pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef {
if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }
}
/// Status-bar statistics for a selection: average, count (non-empty), numerical count, min,
/// max, sum.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
+26 -10
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, serial_from_ymd};
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
@@ -45,6 +45,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
let src_len = if vertical { src.height() } else { src.width() };
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
let mut pictures = Vec::new();
for lane in lanes {
let src_cells: Vec<(CellRef, Option<Cell>)> = (0..src_len)
.map(|i| {
@@ -97,18 +98,23 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
cell.style = style;
}
}
if let Some(picture) = sh.cell_pictures.get(&sc) {
pictures.push((*t, picture.clone()));
}
writes.push((*t, if cell.is_blank() { None } else { Some(cell) }));
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, cell) in &writes {
match cell {
Some(x) => sh.cells.set(*c, x.clone()),
Some(x) if mode == FillMode::Formats => sh.cells.set(*c, x.clone()),
Some(x) => sh.set_cell(*c, x.clone()),
None => {
sh.cells.remove(*c);
sh.remove_cell(*c);
}
}
}
sh.cell_pictures.extend(pictures);
for (c, _) in writes {
cx.touch(sheet, c);
}
@@ -258,15 +264,25 @@ impl Pattern {
}
Pattern::Months { last, first, months } => {
let base = if forward { *last } else { *first };
let d = datetime_from_serial(gridcraft_core::DateSystem::D1900, base)?;
let m = d.month as i64 + months * k * sign;
let s = serial_from_ymd(gridcraft_core::DateSystem::D1900, d.year as i64, m, d.day as i64)?;
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
}
}
}
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
let d = datetime_from_serial(sys, serial)?;
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) + 1;
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
}
fn round15(x: f64) -> f64 {
if x == 0.0 || !x.is_finite() {
return x;
@@ -310,8 +326,8 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
"date" => {
let d = datetime_from_serial(sys, v);
match (unit, d) {
("month", Some(d)) => serial_from_ymd(sys, d.year as i64, d.month as i64 + step as i64, d.day as i64).unwrap_or(v),
("year", Some(d)) => serial_from_ymd(sys, d.year as i64 + step as i64, d.month as i64, d.day as i64).unwrap_or(v),
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("weekday", Some(_)) => {
let mut n = v;
let mut left = step.abs() as i64;
@@ -339,7 +355,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
}
let sh = cx.sheet_mut(sheet)?;
for (c, cell) in &writes {
sh.cells.set(*c, cell.clone());
sh.set_cell(*c, cell.clone());
}
for (c, _) in writes {
cx.touch(sheet, c);
@@ -393,7 +409,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
}
let sh = cx.sheet_mut(sheet)?;
for (c, out) in &writes {
sh.cells.set(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
}
for (c, _) in writes {
cx.touch(sheet, c);
+179 -28
View File
@@ -1,9 +1,9 @@
//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
use gridcraft_core::CellRef;
use gridcraft_model::Workbook;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{Sheet, Workbook};
use crate::{EngineError, Result};
@@ -11,6 +11,8 @@ use crate::{EngineError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
Xlsx,
Xlsb,
Ods,
Csv,
Tsv,
Json,
@@ -22,6 +24,8 @@ impl FileKind {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"xlsb" => FileKind::Xlsb,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
"tsv" | "tab" | "txt" => FileKind::Tsv,
"json" | "scjson" => FileKind::Json,
@@ -36,6 +40,28 @@ impl FileKind {
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!(
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
)));
}
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
return Err(EngineError::Other(format!(
"'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again."
)));
}
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
// whatever it is called; the extension only decides when the bytes don't say.
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
let read = if import == FileKind::Ods { gridcraft_xlsx::read_ods } else { gridcraft_xlsx::read_xlsb };
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
@@ -81,6 +107,8 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
@@ -92,6 +120,109 @@ fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
let st = wb.styles.get(sh.style_id(c));
let mut css = String::new();
if st.font.bold {
css.push_str("font-weight:bold;");
}
if st.font.italic {
css.push_str("font-style:italic;");
}
if let Some(h) = st.font.color.hex(&wb.theme) {
let _ = write!(css, "color:{h};");
}
if st.fill.pattern != gridcraft_model::PatternType::None
&& let Some(h) = st.fill.fg.hex(&wb.theme)
{
let _ = write!(css, "background:{h};");
}
let v = sh.value(c);
let align = match st.align.h {
gridcraft_model::HAlign::Center => "center",
gridcraft_model::HAlign::Right => "right",
gridcraft_model::HAlign::Left => "left",
_ if v.is_number() => "right",
_ => "left",
};
let _ = write!(css, "text-align:{align};");
css
}
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
/// An HTML fragment for the same range as `plain_text`, previously produced by
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
let r = crate::display::clipboard_range(r);
if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES {
return None;
}
let sh = wb.sheet(sheet)?;
let mut out = ClipboardHtml(String::new());
out.write_str("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
out.write_str("<tr>").ok()?;
for col in r.start.col..=r.end.col {
let c = CellRef::new(row, col);
let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
if c != CellRef::new(first_row, merged.start.col) {
continue;
}
let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count();
format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width())
} else {
String::new()
};
let css = html_cell_css(wb, sh, c);
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").ok()?;
// Use the visible selected cell, even when the merge's original anchor
// is outside the range or hidden. This matches the plain-text copy.
let text = crate::display::cell_text(wb, sh, c);
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'&' => out.write_str("&amp;"),
'<' => out.write_str("&lt;"),
'>' => out.write_str("&gt;"),
'"' => out.write_str("&quot;"),
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.write_str("<br>")
}
'\n' => out.write_str("<br>"),
_ => out.write_char(ch),
}
.ok()?;
}
out.write_str("</td>").ok()?;
}
out.write_str("</tr>").ok()?;
}
out.write_str("</table>").ok()?;
Some(out.0)
}
/// Enforce the budget while escaping, before HTML expansion can allocate a large string.
struct ClipboardHtml(String);
impl std::fmt::Write for ClipboardHtml {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) {
return Err(std::fmt::Error);
}
self.0.push_str(s);
Ok(())
}
}
/// A sheet as a standalone HTML table (Save as Web Page).
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
let Some(sh) = wb.sheet(sheet) else { return String::new() };
@@ -112,31 +243,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
continue;
}
let st = wb.styles.get(sh.style_id(c));
let mut css = String::new();
if st.font.bold {
css.push_str("font-weight:bold;");
}
if st.font.italic {
css.push_str("font-style:italic;");
}
if let Some(h) = st.font.color.hex(&wb.theme) {
let _ = write!(css, "color:{h};");
}
if st.fill.pattern != gridcraft_model::PatternType::None
&& let Some(h) = st.fill.fg.hex(&wb.theme)
{
let _ = write!(css, "background:{h};");
}
let v = sh.value(c);
let align = match st.align.h {
gridcraft_model::HAlign::Center => "center",
gridcraft_model::HAlign::Right => "right",
gridcraft_model::HAlign::Left => "left",
_ if v.is_number() => "right",
_ => "left",
};
let _ = write!(css, "text-align:{align};");
let css = html_cell_css(wb, sh, c);
let span = sh
.merges
.iter()
@@ -244,3 +351,47 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
let mut b = vec![0u8; 512 * 3];
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
put(30, &9u16.to_le_bytes());
put(44, &1u32.to_le_bytes());
put(48, &1u32.to_le_bytes());
put(68, &0xFFFF_FFFEu32.to_le_bytes());
for i in 1..109 {
put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
}
put(512, &0xFFFF_FFFDu32.to_le_bytes());
put(516, &0xFFFF_FFFEu32.to_le_bytes());
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() {
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
put(1024 + k * 128, &units);
put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes());
}
b
}
#[test]
fn encrypted_workbook_is_reported_not_misread() {
let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("password-protected"), "got: {msg}");
assert!(!msg.contains("zip"), "must not surface the zip error: {msg}");
}
#[test]
fn legacy_xls_is_not_called_encrypted() {
let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}");
assert!(!msg.contains("password"), "got: {msg}");
}
}
+19
View File
@@ -311,6 +311,25 @@ impl Session {
pub fn take_ui_requests(&mut self) -> Vec<UiRequest> {
std::mem::take(&mut self.ui_requests)
}
/// Runs a command for a caller with no UI (MCP, CLI): queued UI requests are dropped, and a command that only
/// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no
/// file yet, `data.getData`) is an error instead of a silent success.
pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> {
let r = self.execute(id, params);
let requests = self.take_ui_requests();
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
if r.is_ok() && asked_for_file {
let what = match id {
"file.open" => "`path` or `base64`",
"data.getData" | "data.fromTextCsv" => "`path` or `text`",
_ => "`path`",
};
let msg = format!("missing {what}: programmatic calls never open dialogs");
return Err(EngineError::BadParams { cmd: id.to_string(), msg });
}
r
}
}
/// Convenience: the active workbook's active sheet.
+13 -6
View File
@@ -126,18 +126,25 @@ pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result<So
return Err("The source range is too large for a PivotTable.".into());
}
let mut headers: Vec<String> = Vec::new();
// Names taken so far (lower-case), and the next suffix to try for each repeated header, so
// that thousands of equal headers don't take cubic time.
let mut taken: HashSet<String> = HashSet::new();
let mut next_suffix: HashMap<String, usize> = HashMap::new();
let mut formats = Vec::new();
for c in r.start.col..=r.end.col {
let h = crate::display::cell_text(wb, sh, CellRef::new(r.start.row, c)).trim().to_string();
if h.is_empty() {
return Err(FIELD_NAME_ERR.into());
}
let key = h.to_ascii_lowercase();
let mut n = h.clone();
let mut k = 2;
while headers.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
while taken.contains(&n.to_ascii_lowercase()) {
n = format!("{h}{k}");
k += 1;
}
next_suffix.insert(key, k);
taken.insert(n.to_ascii_lowercase());
headers.push(n);
let fmt = if r.end.row > r.start.row {
wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).num_fmt.as_str().to_string()
@@ -1184,11 +1191,11 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
}
let shm = wb.sheet_mut(sheet).ok_or("There's no such sheet.")?;
if let Some(old) = pt.last_range {
shm.cells.take_range(old);
shm.take_cells(old);
}
shm.cells.take_range(area);
shm.take_cells(area);
for (c, cell) in cells {
shm.cells.set(c, cell);
shm.set_cell(c, cell);
}
for (col, wpt) in widths {
if wpt > shm.col_width(col) && !shm.is_col_hidden(col) {
@@ -1205,7 +1212,7 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
let Some(shm) = wb.sheet_mut(sheet) else { return };
let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return };
shm.cells.take_range(r);
shm.take_cells(r);
if let Some(p) = shm.pivots.get_mut(idx) {
p.last_range = None;
}
+1
View File
@@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool {
color: None,
secondary: false,
kind: None,
smooth: false,
}];
}
}
+36 -27
View File
@@ -119,6 +119,10 @@ impl Selection {
/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode).
pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
if dr == 0 && dc == 0 {
// No direction: nowhere to go (the search below would never advance).
return from;
}
let filled = |c: CellRef| sheet.value_ref(c).is_some_and(|v| !v.is_empty());
let Some(mut cur) = from.offset(dr, dc) else { return from };
let here = filled(from);
@@ -175,38 +179,34 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
let filled = |c: CellRef| sheet.value_ref(c).is_some_and(|v| !v.is_empty());
let mut r = RangeRef::cell(c);
for _ in 0..10_000 {
let mut grown = r;
let r0 = r.start.row.saturating_sub(1);
let r1 = (r.end.row + 1).min(MAX_ROWS - 1);
let c0 = r.start.col.saturating_sub(1);
let c1 = (r.end.col + 1).min(MAX_COLS - 1);
// Check the ring around r.
let ring = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1));
let mut any = false;
for (cc, cell) in sheet.cells.iter_range(ring) {
if !r.contains(cc) && (!cell.value.is_empty() || cell.formula.is_some()) {
grown = grown.union(&RangeRef::cell(cc));
any = true;
}
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
let (mut r0, mut r1, mut c0, mut c1) = (c.row, c.row, c.col, c.col);
// Grow one side at a time while the line next to it (diagonals included) has data. Each
// pass scans only the lines next to the region, so tall regions take linear time.
loop {
let before = (r0, r1, c0, c1);
let (lo_c, hi_c) = (c0.saturating_sub(1), (c1 + 1).min(MAX_COLS - 1));
while r0 > 0 && row_has(r0 - 1, lo_c, hi_c) {
r0 -= 1;
}
for (cc, _) in sheet.spill.range(CellRef::new(r0, 0)..=CellRef::new(r1, MAX_COLS - 1)) {
if ring.contains(*cc) && !r.contains(*cc) {
grown = grown.union(&RangeRef::cell(*cc));
any = true;
}
while r1 + 1 < MAX_ROWS && row_has(r1 + 1, lo_c, hi_c) {
r1 += 1;
}
if !any || grown == r {
let (lo_r, hi_r) = (r0.saturating_sub(1), (r1 + 1).min(MAX_ROWS - 1));
while c0 > 0 && col_has(c0 - 1, lo_r, hi_r) {
c0 -= 1;
}
while c1 + 1 < MAX_COLS && col_has(c1 + 1, lo_r, hi_r) {
c1 += 1;
}
if (r0, r1, c0, c1) == before {
break;
}
r = grown;
}
if r.is_single() && !filled(c) {
return RangeRef::cell(c);
}
r
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
}
#[cfg(test)]
@@ -222,6 +222,8 @@ mod tests {
}
s.set_value(CellRef::new(10, 0), Value::Number(1.0));
assert_eq!(jump(&s, CellRef::new(0, 0), 1, 0), CellRef::new(4, 0));
// Without a direction it stays put (it used to loop forever from a blank cell).
assert_eq!(jump(&s, CellRef::new(7, 0), 0, 0), CellRef::new(7, 0));
assert_eq!(jump(&s, CellRef::new(4, 0), 1, 0), CellRef::new(10, 0));
assert_eq!(jump(&s, CellRef::new(10, 0), 1, 0), CellRef::new(MAX_ROWS - 1, 0));
assert_eq!(jump(&s, CellRef::new(0, 0), 0, 1), CellRef::new(0, MAX_COLS - 1));
@@ -239,6 +241,13 @@ mod tests {
assert_eq!(current_region(&s, CellRef::new(3, 2)).a1(), "B3:D5");
assert_eq!(current_region(&s, CellRef::new(5, 4)).a1(), "B3:E6");
assert_eq!(current_region(&s, CellRef::new(20, 20)).a1(), "U21");
// Taller than 10,000 rows: it used to stop growing after 10,000 steps.
let mut s = Sheet::new("S");
for r in 0..20_000 {
s.set_value(CellRef::new(r, 0), Value::Number(r as f64));
s.set_value(CellRef::new(r, 1), Value::Number(1.0));
}
assert_eq!(current_region(&s, CellRef::new(5, 0)).a1(), "A1:B20000");
}
#[test]
+155 -2
View File
@@ -1,7 +1,7 @@
//! Table styles: how a cell inside a table looks (our own palettes, keyed by the familiar
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
use gridcraft_core::CellRef;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
@@ -9,7 +9,7 @@ pub fn parse_style_name(name: &str) -> (&'static str, u32) {
let rest = name.strip_prefix("TableStyle").unwrap_or(name);
for fam in ["Light", "Medium", "Dark"] {
if let Some(n) = rest.strip_prefix(fam) {
let k = n.parse().unwrap_or(2);
let k = n.parse::<u32>().unwrap_or(2).max(1); // the gallery starts at 1; 0 would underflow `n - 1`
return (fam, k);
}
}
@@ -147,3 +147,156 @@ pub fn gallery() -> Vec<String> {
}
v
}
/// Editing a table's header row renames the column, as in Excel: an empty or repeated header
/// gets a unique name written back into the cell, and structured references follow the rename.
pub(crate) fn sync_headers(cx: &mut crate::cmd::Ctx) {
if cx.wb.sheets.iter().all(|s| s.tables.is_empty()) {
return;
}
// (sheet, table name, table range, old column name, new column name)
let mut renames: Vec<(usize, String, RangeRef, String, String)> = Vec::new();
let heads: Vec<(usize, RangeRef)> = (0..cx.wb.sheets.len())
.flat_map(|si| cx.wb.sheet(si).into_iter().flat_map(move |sh| sh.tables.iter().filter(|t| t.header_row).map(move |t| (si, t.range))))
.map(|(si, r)| (si, RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col))))
.collect();
// A structural edit can move anything into a header row: then every header is checked.
let changed: std::collections::BTreeSet<(usize, CellRef)> = if cx.structural {
heads.iter().flat_map(|(s, r)| r.iter().map(move |c| (*s, c))).collect()
} else {
cx.changed.iter().filter(|(si, c)| heads.iter().any(|(s, r)| s == si && r.contains(*c))).copied().collect()
};
// Per table: lower-case column name → column, and the next number to try for a repeated name
// (both kept up to date, so a wide header row stays linear).
type Names = (std::collections::HashMap<String, usize>, std::collections::HashMap<String, usize>);
let mut names: std::collections::HashMap<(usize, usize), Names> = std::collections::HashMap::new();
for (si, c) in changed {
let Some(sh) = cx.wb.sheet(si) else { continue };
let Some(ti) = sh.tables.iter().position(|t| t.header_row && c.row == t.range.start.row && t.range.contains(c)) else { continue };
let Some(t) = sh.tables.get(ti) else { continue };
let col = (c.col - t.range.start.col) as usize;
let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
let text = crate::display::cell_text(&cx.wb, sh, c);
let is_text = sh.value(c).is_text();
if text == old && is_text {
continue;
}
let (taken, next) = names
.entry((si, ti))
.or_insert_with(|| (t.columns.iter().enumerate().map(|(i, x)| (x.name.to_lowercase(), i)).collect(), Default::default()));
if taken.get(&old.to_lowercase()) == Some(&col) {
taken.remove(&old.to_lowercase());
}
let base = if text.trim().is_empty() { format!("Column{}", col + 1) } else { text.clone() };
let k = next.entry(base.to_lowercase()).or_insert(2);
let mut name = base.clone();
while taken.contains_key(&name.to_lowercase()) {
name = format!("{base}{k}");
*k += 1;
}
taken.insert(name.to_lowercase(), col);
let (table, range) = (t.name.clone(), t.range);
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
if let Some(x) = sh.tables.get_mut(ti).and_then(|t| t.columns.get_mut(col)) {
x.name = name.clone();
}
// Headers are text: a number typed there, or a generated name, is written back as text.
if name != text || !is_text {
let style = sh.style_id(c);
sh.cells.set(c, Cell { value: gridcraft_core::Value::text(name.as_str()), style, ..Default::default() });
}
if !old.eq_ignore_ascii_case(&name) {
renames.push((si, table, range, old, name));
}
}
if renames.is_empty() {
return;
}
// Structured references follow the new names.
for i in 0..cx.wb.sheets.len() {
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> =
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
for (c, f) in formulas {
let Some(e) = f.expr() else { continue };
let mut hit = false;
let ne = e.map(&mut |x| match x {
gridcraft_formula::Expr::Struct(mut st) => {
for (si, table, range, old, name) in &renames {
let this = if st.table.is_empty() { i == *si && range.contains(c) } else { st.table.eq_ignore_ascii_case(table) };
if this {
for end in [&mut st.col_start, &mut st.col_end].into_iter().flatten() {
if end.eq_ignore_ascii_case(old) {
*end = name.clone();
hit = true;
}
}
}
}
gridcraft_formula::Expr::Struct(st)
}
o => o,
});
if hit && let Some(cell) = cx.wb.sheet_mut(i).and_then(|s| s.cells.get_mut(c)) {
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
}
}
}
cx.structural = true;
}
#[cfg(test)]
mod tests {
use gridcraft_core::Value;
use serde_json::json;
use crate::Session;
#[test]
fn editing_a_header_renames_the_column() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b", "c"], [1, 2, 3]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:C2", "header": true})).unwrap();
s.execute("cell.set", json!({"cell": "E1", "input": "=SUM(Table1[a])"})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "zz"})).unwrap();
s.execute("edit.clearContents", json!({"range": "B1"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "ZZ"})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "E1")["formula"], "=SUM(Table1[zz])");
assert_eq!(get(&mut s, "E1")["value"], json!(1.0));
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
let names: Vec<&str> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["zz", "Column2", "ZZ2"]);
assert_eq!(sh.value(gridcraft_core::CellRef::new(0, 1)), Value::from("Column2"));
assert_eq!(sh.value(gridcraft_core::CellRef::new(0, 2)), Value::from("ZZ2"));
s.execute("range.setValues", json!({"range": "A1", "values": [["x", "x", 5]]})).unwrap();
let sh = s.doc().unwrap().wb.sheet(0).unwrap();
let names: Vec<&str> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["x", "x2", "5"]);
assert_eq!(sh.value(gridcraft_core::CellRef::new(0, 2)), Value::from("5"));
}
#[test]
fn go_to_a_table_selects_its_data() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"], [1, 2], [3, 4]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
assert_eq!(s.execute("edit.goTo", json!({"reference": "Table1"})).unwrap()["selection"], "A2:B3");
}
}
#[cfg(test)]
mod style_tests {
use super::*;
#[test]
fn zero_numbered_styles_do_not_underflow() {
assert_eq!(parse_style_name("TableStyleLight0"), ("Light", 1));
assert_eq!(parse_style_name("TableStyleMedium0"), ("Medium", 1));
for name in ["TableStyleLight0", "TableStyleMedium0", "TableStyleDark0"] {
let _ = look(name);
}
}
}
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
use gridcraft_core::{CellRef, Value};
use gridcraft_engine::Session;
use serde_json::{Value as Json, json};
fn workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn run(s: &mut Session, command: &str, params: Json) {
s.execute(command, params).unwrap();
}
fn set(s: &mut Session, cell: &str, input: &str) {
run(s, "cell.set", json!({"cell": cell, "input": input}));
}
fn activate(s: &mut Session, sheet: &str) {
run(s, "sheet.activate", json!({"sheet": sheet}));
}
fn value(s: &Session, sheet: &str, cell: &str) -> Value {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap())
}
fn formula(s: &Session, sheet: &str, cell: &str) -> String {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone()
}
fn cut_to(s: &mut Session, source: &str, range: &str, destination: &str, at: &str) {
activate(s, source);
run(s, "edit.cut", json!({"range": range}));
activate(s, destination);
run(s, "edit.paste", json!({"at": at}));
}
#[test]
fn cross_sheet_cut_preserves_total_through_undo_redo_and_xlsx() {
let mut s = workbook();
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[10], [20]]}));
set(&mut s, "B1", "=SUM(A1:A2)");
run(&mut s, "home.insertSheet", json!({"name": "Sheet2"}));
cut_to(&mut s, "Sheet1", "A1:A2", "Sheet2", "C3");
let assert_moved = |s: &Session| {
assert_eq!(value(s, "Sheet1", "A1"), Value::Empty);
assert_eq!(value(s, "Sheet1", "A2"), Value::Empty);
assert_eq!(value(s, "Sheet2", "C3"), Value::Number(10.0));
assert_eq!(value(s, "Sheet2", "C4"), Value::Number(20.0));
assert_eq!(value(s, "Sheet1", "B1"), Value::Number(30.0));
assert_eq!(formula(s, "Sheet1", "B1"), "SUM(Sheet2!C3:C4)");
};
assert_moved(&s);
run(&mut s, "edit.undo", json!({}));
assert_eq!(value(&s, "Sheet1", "A1"), Value::Number(10.0));
assert_eq!(value(&s, "Sheet1", "A2"), Value::Number(20.0));
assert_eq!(value(&s, "Sheet2", "C3"), Value::Empty);
assert_eq!(value(&s, "Sheet2", "C4"), Value::Empty);
assert_eq!(value(&s, "Sheet1", "B1"), Value::Number(30.0));
assert_eq!(formula(&s, "Sheet1", "B1"), "SUM(A1:A2)");
run(&mut s, "edit.redo", json!({}));
assert_moved(&s);
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
run(&mut s, "file.open", json!({"name": "moved.xlsx", "base64": saved["base64"]}));
assert_moved(&s);
}
#[test]
fn references_on_all_sheets_follow_cut_but_destination_local_refs_do_not() {
let mut s = workbook();
run(&mut s, "sheet.rename", json!({"name": "Source Data"}));
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[10], [20]]}));
set(&mut s, "B1", "=SUM($A$1:$A$2)");
run(&mut s, "formulas.defineName", json!({"name": "MovedData", "refersTo": "='Source Data'!$A$1:$A$2"}));
run(&mut s, "home.insertSheet", json!({"name": "Target's Data"}));
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[100], [200]]}));
set(&mut s, "B1", "=SUM('Source Data'!$A$1:$A$2)");
set(&mut s, "B2", "=SUM(A1:A2)");
run(&mut s, "home.insertSheet", json!({"name": "Summary"}));
set(&mut s, "A1", "='Source Data'!$A$1+'Source Data'!A$2");
set(&mut s, "A2", "=SUM(MovedData)");
cut_to(&mut s, "Source Data", "A1:A2", "Target's Data", "C3");
for (sheet, cell) in [("Source Data", "B1"), ("Target's Data", "B1"), ("Summary", "A1"), ("Summary", "A2")] {
assert_eq!(value(&s, sheet, cell), Value::Number(30.0), "{sheet}!{cell}");
}
assert_eq!(formula(&s, "Source Data", "B1"), "SUM('Target''s Data'!$C$3:$C$4)");
assert_eq!(formula(&s, "Summary", "A1"), "'Target''s Data'!$C$3+'Target''s Data'!C$4");
assert_eq!(value(&s, "Target's Data", "B2"), Value::Number(300.0));
assert_eq!(formula(&s, "Target's Data", "B2"), "SUM(A1:A2)");
// Recalculation must keep tracking the destination after the move, not just retain a cache.
activate(&mut s, "Target's Data");
set(&mut s, "C3", "40");
for (sheet, cell) in [("Source Data", "B1"), ("Target's Data", "B1"), ("Summary", "A1"), ("Summary", "A2")] {
assert_eq!(value(&s, sheet, cell), Value::Number(60.0), "{sheet}!{cell}");
}
}
#[test]
fn moved_formulas_keep_external_source_refs_and_move_internal_refs_once() {
let mut s = workbook();
run(&mut s, "sheet.rename", json!({"name": "Source Data"}));
set(&mut s, "A1", "10");
set(&mut s, "D1", "7");
set(&mut s, "A2", "=$A$1+$D$1+'Source Data'!A1");
set(&mut s, "A3", "=A2*2");
run(&mut s, "home.insertSheet", json!({"name": "Destination"}));
set(&mut s, "D1", "1000");
// Overlapping coordinates on different sheets catch accidental double adjustment.
cut_to(&mut s, "Source Data", "A1:A3", "Destination", "A2");
assert_eq!(value(&s, "Destination", "A2"), Value::Number(10.0));
assert_eq!(value(&s, "Destination", "A3"), Value::Number(27.0));
assert_eq!(value(&s, "Destination", "A4"), Value::Number(54.0));
assert_eq!(value(&s, "Source Data", "D1"), Value::Number(7.0));
assert!(formula(&s, "Destination", "A3").contains("'Source Data'!$D$1"));
for cell in ["A1", "A2", "A3"] {
assert_eq!(value(&s, "Source Data", cell), Value::Empty);
}
activate(&mut s, "Source Data");
set(&mut s, "D1", "8");
assert_eq!(value(&s, "Destination", "A3"), Value::Number(28.0));
assert_eq!(value(&s, "Destination", "A4"), Value::Number(56.0));
}
#[test]
fn same_sheet_overlapping_cut_still_updates_references_once() {
let mut s = workbook();
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[10], [20]]}));
set(&mut s, "B1", "=SUM(A1:A2)");
set(&mut s, "A3", "=A1+A2");
run(&mut s, "edit.cut", json!({"range": "A1:A3"}));
run(&mut s, "edit.paste", json!({"at": "A2"}));
assert_eq!(value(&s, "Sheet1", "A1"), Value::Empty);
assert_eq!(value(&s, "Sheet1", "A2"), Value::Number(10.0));
assert_eq!(value(&s, "Sheet1", "A3"), Value::Number(20.0));
assert_eq!(value(&s, "Sheet1", "A4"), Value::Number(30.0));
assert_eq!(value(&s, "Sheet1", "B1"), Value::Number(30.0));
assert_eq!(formula(&s, "Sheet1", "A4"), "A2+A3");
assert_eq!(formula(&s, "Sheet1", "B1"), "SUM(A2:A3)");
}
+70 -2
View File
@@ -56,6 +56,14 @@ pub enum Edit {
Delete { axis: Axis, at: u32, count: u32 },
/// Cut-paste of a block: references into `from` now point into the block at `to` (top-left).
Move { from: RangeRef, to_row: u32, to_col: u32 },
/// Insert Cells shifting down (`Rows`) or right (`Cols`): the cells of `range` and those
/// below (right of) it move by its height (width), within its columns (rows) only.
InsertCells { axis: Axis, range: RangeRef },
/// Delete Cells shifting up (`Rows`) or left (`Cols`): `range` goes and the cells below
/// (right of) it move back by its height (width), within its columns (rows) only.
DeleteCells { axis: Axis, range: RangeRef },
/// Cut-paste to another sheet; fully contained references follow the destination sheet too.
MoveToSheet { from: RangeRef, to_sheet: String, to_row: u32, to_col: u32 },
}
/// Adjusts a formula living on sheet `host` after `edit` on sheet `target`.
@@ -158,6 +166,21 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
// Like inserting/deleting lines, for references that lie within the shifted
// columns (rows); Excel leaves references reaching outside them as they are.
let span = r.range();
let (inside, at, count) = match axis {
Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()),
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
};
if !inside {
return Some(r.clone());
}
let axis = *axis;
let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
adjust_ref(r, &lines)
}
Edit::Move { from, to_row, to_col } => {
let dr = *to_row as i64 - from.start.row as i64;
let dc = *to_col as i64 - from.start.col as i64;
@@ -176,6 +199,26 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
if !from.contains_range(&r.range()) {
return Some(r.clone());
}
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
// Whole-row/column references have no anchors, but their indices must move too.
moved.kind = match &r.kind {
RefKind::Rows(a, aa, b, ba) => {
let dr = *to_row as i64 - from.start.row as i64;
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
}
RefKind::Cols(a, aa, b, ba) => {
let dc = *to_col as i64 - from.start.col as i64;
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
}
_ => moved.kind,
};
moved.sheet = SheetSel::Named(to_sheet.clone());
Some(moved)
}
}
}
@@ -197,14 +240,39 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr {
}
Expr::Ref(r)
}
// Sheet-qualified defined name `Old!MyName` is stored as one string.
Expr::Name(n) => match n.split_at_checked(old.len()) {
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => {
Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new)))
}
_ => Expr::Name(n),
},
other => other,
})
}
/// References to a deleted sheet become `#REF!`.
pub fn delete_sheet(e: Expr, name: &str) -> Expr {
/// References to a deleted sheet become `#REF!`. `order` lists the sheet names (before the deletion) in tab order:
/// a 3-D span that loses an end sheet shrinks to the neighbouring surviving sheet, as in Excel.
pub fn delete_sheet(e: Expr, name: &str, order: &[String]) -> Expr {
let pos = |n: &str| order.iter().position(|o| o.eq_ignore_ascii_case(n));
e.map(&mut |x| match x {
Expr::Ref(r) if r.sheet_name().is_some_and(|n| n.eq_ignore_ascii_case(name)) => Expr::Error(CellError::Ref),
Expr::Ref(mut r) => {
if let SheetSel::Span(a, b) = &r.sheet
&& (a.eq_ignore_ascii_case(name) || b.eq_ignore_ascii_case(name))
{
let (Some(ia), Some(ib)) = (pos(a), pos(b)) else { return Expr::Ref(r) };
if ia == ib {
return Expr::Error(CellError::Ref);
}
// The deleted end moves one sheet towards the other end; the span keeps its direction.
let inward = |from: usize, to: usize| order.get(if from < to { from + 1 } else { from.saturating_sub(1) });
let (na, nb) = if a.eq_ignore_ascii_case(name) { (inward(ia, ib), order.get(ib)) } else { (order.get(ia), inward(ib, ia)) };
let (Some(na), Some(nb)) = (na, nb) else { return Expr::Error(CellError::Ref) };
r.sheet = if na.eq_ignore_ascii_case(nb) { SheetSel::Named(na.clone()) } else { SheetSel::Span(na.clone(), nb.clone()) };
}
Expr::Ref(r)
}
other => other,
})
}
+3
View File
@@ -88,6 +88,9 @@ pub enum UnOp {
Percent,
/// Implicit intersection `@`.
At,
/// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the
/// cell (`ANCHORARRAY` in files).
Spill,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+204
View File
@@ -0,0 +1,204 @@
//! Localized aliases accepted at user-entry boundaries; file syntax stays English.
use crate::{Expr, ParseError, parser};
/// Formula-entry syntax selected by the interface language. Other interface languages
/// currently use the canonical English function table until a translation table is supplied.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FormulaLocale {
#[default]
En,
Es,
}
const SPANISH: &[(&str, &str)] = &[
("SUM", "SUMA"),
("AVERAGE", "PROMEDIO"),
("IF", "SI"),
("COUNT", "CONTAR"),
("SUMIF", "SUMAR.SI"),
("COUNTIF", "CONTAR.SI"),
("VLOOKUP", "BUSCARV"),
("TODAY", "HOY"),
("TRUE", "VERDADERO"),
("FALSE", "FALSO"),
];
impl FormulaLocale {
pub fn list_separator(self) -> char {
if self == Self::Es { ';' } else { ',' }
}
pub fn decimal_separator(self) -> char {
if self == Self::Es { ',' } else { '.' }
}
pub fn array_column_separator(self) -> char {
if self == Self::Es { '\\' } else { ',' }
}
pub fn function_name(self, canonical: &str) -> &str {
if self == Self::Es {
SPANISH.iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, es)| *es).unwrap_or(canonical)
} else {
canonical
}
}
pub fn canonical_name(self, localized: &str) -> &str {
if self == Self::Es {
SPANISH.iter().find(|(_, es)| es.eq_ignore_ascii_case(localized)).map(|(en, _)| *en).unwrap_or(localized)
} else {
localized
}
}
}
/// Parses UI input using the selected function table and separators. Existing names and
/// LET/LAMBDA bindings take precedence over aliases. File and API syntax uses [`crate::parse`].
pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {
let mut expr = parser::parse_syntax(src, locale)?;
if locale == FormulaLocale::Es {
normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;
}
Ok(expr)
}
/// Prints canonical expressions for editing. Name collisions retain the canonical spelling
/// so displaying then committing a formula never changes a defined-name reference.
pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<String, ParseError> {
// Check before cloning/printing: a flat operator chain can produce a deep AST even
// though the parser itself did not recurse deeply.
let mut pending = vec![(expr, 0usize)];
while let Some((node, depth)) = pending.pop() {
if depth > parser::MAX_DEPTH {
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
}
match node {
Expr::Unary(_, child) | Expr::Paren(child) => pending.push((child, depth + 1)),
Expr::Binary(_, left, right) => {
pending.push((left, depth + 1));
pending.push((right, depth + 1));
}
Expr::Call(_, args) => pending.extend(args.iter().map(|e| (e, depth + 1))),
Expr::Invoke(callee, args) => {
pending.push((callee, depth + 1));
pending.extend(args.iter().map(|e| (e, depth + 1)));
}
Expr::Array(rows) => pending.extend(rows.iter().flatten().map(|e| (e, depth + 1))),
_ => {}
}
}
if locale == FormulaLocale::En {
return Ok(crate::print(expr));
}
let mut expr = expr.clone();
normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?;
let canonical = crate::print(&expr);
let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
let mut out = String::new();
let mut array_depth = 0usize;
for token in tokens {
use crate::lexer::Tok;
let text = canonical.get(token.start..token.end).unwrap_or("");
match token.tok {
Tok::LBrace => {
array_depth += 1;
out.push_str(text);
}
Tok::RBrace => {
array_depth = array_depth.saturating_sub(1);
out.push_str(text);
}
Tok::Number(_) => out.push_str(&text.replace('.', &locale.decimal_separator().to_string())),
Tok::Comma => out.push(if array_depth > 0 { locale.array_column_separator() } else { locale.list_separator() }),
_ => out.push_str(text),
}
}
Ok(out)
}
pub(crate) fn boolean_alias(name: &str) -> Option<bool> {
if name.eq_ignore_ascii_case("VERDADERO") {
Some(true)
} else if name.eq_ignore_ascii_case("FALSO") {
Some(false)
} else {
None
}
}
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {
if depth > parser::MAX_DEPTH {
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
}
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name);
match expr {
Expr::Bool(value) if display => {
let alias = if *value { "VERDADERO" } else { "FALSO" };
if !bound(alias, bindings) {
*expr = Expr::Name(alias.into());
}
}
Expr::Name(name) if !display && !bound(name, bindings) => {
if let Some(value) = boolean_alias(name) {
*expr = Expr::Bool(value);
}
}
Expr::Call(name, args) if name == "LET" && args.len() >= 3 && !args.len().is_multiple_of(2) => {
let saved = bindings.len();
if let Some((body, pairs)) = args.split_last_mut() {
for pair in pairs.chunks_mut(2) {
if let [declaration, value] = pair {
// The new name is not in scope in its own value expression.
normalize(value, known_name, bindings, depth + 1, display)?;
if let Expr::Name(name) = declaration {
bindings.push(name.clone());
}
}
}
normalize(body, known_name, bindings, depth + 1, display)?;
}
bindings.truncate(saved);
}
Expr::Call(name, args) if name == "LAMBDA" => {
let saved = bindings.len();
if let Some((body, params)) = args.split_last_mut() {
for param in params {
if let Expr::Name(name) = param {
bindings.push(name.clone());
}
}
normalize(body, known_name, bindings, depth + 1, display)?;
}
bindings.truncate(saved);
}
Expr::Call(name, args) => {
let alias = if display { FormulaLocale::Es.function_name(name) } else { FormulaLocale::Es.canonical_name(name) };
if !bound(name, bindings) && (!display || !bound(alias, bindings)) {
*name = alias.to_string();
}
for arg in args {
normalize(arg, known_name, bindings, depth + 1, display)?;
}
}
Expr::Invoke(callee, args) => {
normalize(callee, known_name, bindings, depth + 1, display)?;
for arg in args {
normalize(arg, known_name, bindings, depth + 1, display)?;
}
}
Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?,
Expr::Binary(_, left, right) => {
normalize(left, known_name, bindings, depth + 1, display)?;
normalize(right, known_name, bindings, depth + 1, display)?;
}
Expr::Array(rows) if display => {
for row in rows {
for child in row {
normalize(child, known_name, bindings, depth + 1, display)?;
}
}
}
// Array elements have already been parsed as literals. Sheet/structured references
// contain user-defined labels and must not be translated.
_ => {}
}
Ok(())
}
+24 -8
View File
@@ -1,4 +1,4 @@
//! Tokenizer for Excel formulas (A1 style, en-US separators).
//! Tokenizer for Excel formulas (A1 style, canonical or selected UI separators).
//!
//! Token spans are byte offsets into the source, which the editor uses to colour references.
@@ -51,12 +51,18 @@ fn is_word_char(c: char) -> bool {
/// Tokenizes formula text (without the leading `=`).
pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
tokenize_locale(src, crate::FormulaLocale::En)
}
/// Tokenizes UI syntax while retaining byte spans in the original text.
pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<Token>, LexError> {
let chars: Vec<(usize, char)> = src.char_indices().collect();
let n = chars.len();
let pos_of = |i: usize| chars.get(i).map(|c| c.0).unwrap_or(src.len());
let ch = |i: usize| chars.get(i).map(|c| c.1);
let mut out = Vec::new();
let mut i = 0;
let mut array_depth = 0usize;
while i < n {
let start = pos_of(i);
let Some(c) = ch(i) else { break };
@@ -124,6 +130,11 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
push(&mut out, Tok::Sheet(a, b), j + 1);
i = j + 1;
}
'#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => {
// Spill range operator right after a reference: `A1#`.
push(&mut out, Tok::Op("#"), i + 1);
i += 1;
}
'#' => {
// Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct).
let rest = src.get(start..).unwrap_or("");
@@ -167,7 +178,7 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
push(&mut out, Tok::Struct(inner), j);
i = j;
}
'0'..='9' | '.' if c != '.' || ch(i + 1).is_some_and(|d| d.is_ascii_digit()) => {
c if c.is_ascii_digit() || (c == locale.decimal_separator() && ch(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
// Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers
// followed by ':' and digits are row refs: lex as Word.
let mut j = i;
@@ -188,7 +199,7 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
i = j;
continue;
}
if ch(j) == Some('.') {
if ch(j) == Some(locale.decimal_separator()) {
j += 1;
while ch(j).is_some_and(|d| d.is_ascii_digit()) {
j += 1;
@@ -207,7 +218,10 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
}
}
let text = src.get(start..pos_of(j)).unwrap_or("");
let v: f64 = text.parse().map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?;
let v: f64 = text
.replace(locale.decimal_separator(), ".")
.parse()
.map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?;
push(&mut out, Tok::Number(v), j);
i = j;
}
@@ -220,14 +234,16 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
i += 1;
}
'{' => {
array_depth += 1;
push(&mut out, Tok::LBrace, i + 1);
i += 1;
}
'}' => {
array_depth = array_depth.saturating_sub(1);
push(&mut out, Tok::RBrace, i + 1);
i += 1;
}
',' => {
c if (array_depth == 0 && c == locale.list_separator()) || (array_depth > 0 && c == locale.array_column_separator()) => {
push(&mut out, Tok::Comma, i + 1);
i += 1;
}
@@ -268,7 +284,7 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
}
c if is_word_char(c) => {
let mut j = i;
while ch(j).is_some_and(is_word_char) {
while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
j += 1;
}
let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default();
@@ -278,13 +294,13 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
} else if ch(j) == Some(':') && {
// Sheet1:Sheet3!A1 — look ahead for word then '!'
let mut k = j + 1;
while ch(k).is_some_and(is_word_char) {
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
k += 1;
}
k > j + 1 && ch(k) == Some('!')
} {
let mut k = j + 1;
while ch(k).is_some_and(is_word_char) {
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
k += 1;
}
let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default();
+2
View File
@@ -5,11 +5,13 @@
pub mod adjust;
pub mod ast;
mod input;
pub mod lexer;
pub mod parser;
pub mod printer;
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
pub use input::{FormulaLocale, parse_input, print_input};
pub use parser::{ParseError, normalize_function_name, parse};
pub use printer::{print, print_r1c1, quote_sheet};
+26 -6
View File
@@ -6,7 +6,7 @@ use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
use gridcraft_core::{CellError, MAX_ROWS};
use crate::ast::*;
use crate::lexer::{Tok, Token, tokenize};
use crate::lexer::{Tok, Token, tokenize_locale};
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
#[error("{msg}")]
@@ -15,15 +15,19 @@ pub struct ParseError {
pub pos: usize,
}
const MAX_DEPTH: usize = 256;
pub(crate) const MAX_DEPTH: usize = 256;
/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped
/// for `=`.
pub fn parse(src: &str) -> Result<Expr, ParseError> {
parse_syntax(src, crate::FormulaLocale::En)
}
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
let body = src.strip_prefix('=').unwrap_or(src);
let toks = tokenize(body).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
let toks = significant_spaces(toks);
let mut p = Parser { toks, i: 0, depth: 0 };
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
let e = p.expr(0, false)?;
match p.peek() {
Tok::Eof => Ok(e),
@@ -62,6 +66,7 @@ struct Parser {
toks: Vec<Token>,
i: usize,
depth: usize,
input: bool,
}
fn word_is_cols(w: &str) -> Option<(u32, bool)> {
@@ -223,7 +228,14 @@ impl Parser {
Tok::LBrace => self.array(),
Tok::Func(name) => {
let name = normalize_function_name(&name);
let args = self.args()?;
let mut args = self.args()?;
// Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`.
if name == "ANCHORARRAY"
&& let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice()
&& let Some(r) = args.pop()
{
return Ok(Expr::Unary(UnOp::Spill, Box::new(r)));
}
Ok(Expr::Call(name, args))
}
Tok::Sheet(a, b) => {
@@ -263,7 +275,12 @@ impl Parser {
self.next();
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) }));
}
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }));
let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) });
if *self.peek() == Tok::Op("#") {
self.next();
return Ok(Expr::Unary(UnOp::Spill, Box::new(cell)));
}
return Ok(cell);
}
let second = match self.peek_at(1) {
Tok::Word(w2) => Some(w2.clone()),
@@ -312,6 +329,9 @@ impl Parser {
},
Tok::Text(s) => Expr::Text(Arc::from(s)),
Tok::Bool(b) => Expr::Bool(b),
Tok::Word(w) if self.input => crate::input::boolean_alias(&w)
.map(Expr::Bool)
.ok_or_else(|| self.err(format!("array constants may only contain constants, found {}", describe(&Tok::Word(w)))))?,
Tok::Error(e) => Expr::Error(e),
t => return Err(self.err(format!("array constants may only contain constants, found {}", describe(&t)))),
};
+7
View File
@@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
write_expr(s, x, r1c1);
s.push('%');
}
UnOp::Spill => {
write_expr(s, x, r1c1);
// A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`.
if !matches!(**x, Expr::Error(_)) {
s.push('#');
}
}
},
Expr::Binary(op, a, b) => {
write_expr(s, a, r1c1);
+77
View File
@@ -52,6 +52,34 @@ fn normalises() {
assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1");
}
#[test]
fn spill_operator() {
for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] {
assert_eq!(rt(f), f);
}
assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _)));
// Files spell it ANCHORARRAY.
assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#");
assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)");
// Only a cell can spill.
assert!(parse("A1:B2#").is_err());
assert!(parse("Name#").is_err());
// Deleting the anchor's sheet leaves #REF!.
assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data", &[])), "SUM(#REF!)");
}
#[test]
fn delete_sheet_shrinks_3d_span() {
let order: Vec<String> = ["Sheet1", "Sheet2", "Sheet3", "Results"].iter().map(|s| s.to_string()).collect();
let del = |f: &str, n: &str| print(&crate::adjust::delete_sheet(parse(f).unwrap(), n, &order));
assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1"), "SUM(Sheet2:Sheet3!A1)");
assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3"), "SUM(Sheet1:Sheet2!A1)");
assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1"), "SUM(Sheet3:Sheet2!A1)");
assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2"), "SUM(Sheet1:Sheet3!A1)");
assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1"), "SUM(Sheet2!A1)");
assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1"), "#REF!+Sheet3!A1");
}
#[test]
fn precedence() {
let e = parse("1+2*3").unwrap();
@@ -120,6 +148,24 @@ fn insert_delete() {
assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)");
}
#[test]
fn insert_delete_cells() {
let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap();
let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e));
// Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't.
let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") };
assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)");
// Insert B2 shifting right.
let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") };
assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)");
// Delete A3:A4 shifting up: references into it become #REF!, those below move up.
let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") };
assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)");
// Delete B1:C2 shifting left.
let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") };
assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)");
}
#[test]
fn moves_and_renames() {
let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 };
@@ -129,6 +175,37 @@ fn moves_and_renames() {
assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2");
}
#[test]
fn rename_sheet_updates_qualified_names() {
let e = parse("Data!MyName+MyName+Other!MyName").unwrap();
assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName");
let e = parse("Data!MyName").unwrap();
assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName");
}
#[test]
fn moves_between_sheets() {
let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_sheet: "New Name".into(), to_row: 9, to_col: 3 };
for (host, input, expected) in [
("Source", "A1+$B$2+C3+SUM(A1:B2)", "'New Name'!D10+'New Name'!$E$11+C3+SUM('New Name'!D10:E11)"),
("Other", "A1+sOuRcE!$B2+Other!B2", "A1+'New Name'!$E11+Other!B2"),
("Source", "SUM(A1:C3)+SUM(Source:Other!A1)", "SUM(A1:C3)+SUM(Source:Other!A1)"),
] {
assert_eq!(print(&adjust(parse(input).unwrap(), host, "Source", &mv)), expected);
}
let edge = Edit::MoveToSheet {
from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(),
to_sheet: "New Name".into(),
to_row: gridcraft_core::MAX_ROWS - 1,
to_col: 3,
};
assert_eq!(print(&adjust(parse("B2").unwrap(), "Source", "Source", &edge)), "#REF!");
for (from, input, row, col, expected) in [("A:A", "$A:$A", 0, 2, "Dest!$C:$C"), ("1:1", "$1:$1", 2, 0, "Dest!$3:$3")] {
let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse(from).unwrap(), to_sheet: "Dest".into(), to_row: row, to_col: col };
assert_eq!(print(&adjust(parse(input).unwrap(), "Source", "Source", &mv)), expected);
}
}
#[test]
fn r1c1() {
let e = parse("A1+$B$2+SUM(C:C)").unwrap();
+100
View File
@@ -0,0 +1,100 @@
use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input};
fn input(src: &str) -> String {
print(&parse_input(src, FormulaLocale::Es, |_| false).unwrap())
}
#[test]
fn spanish_aliases_and_argument_separators() {
for (src, expected) in [
("=suma(A1:A10)", "SUM(A1:A10)"),
("PROMEDIO(A1:A10)", "AVERAGE(A1:A10)"),
("SI(A1>0;\"Sí\";\"No\")", "IF(A1>0,\"Sí\",\"No\")"),
("CONTAR(A1:A10)", "COUNT(A1:A10)"),
("SUMAR.SI(A1:A10;\">0\")", "SUMIF(A1:A10,\">0\")"),
("CONTAR.SI(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"),
("BUSCARV(A2;D:E;2;FALSO)", "VLOOKUP(A2,D:E,2,FALSE)"),
("HOY()", "TODAY()"),
("=verdadero", "TRUE"),
("=falso", "FALSE"),
("VERDADERO()", "TRUE()"),
("FALSO()", "FALSE()"),
("SUMA(1;SUM(2;3);PROMEDIO(4;6))", "SUM(1,SUM(2,3),AVERAGE(4,6))"),
("SI(VERDADERO;;)", "IF(TRUE,,)"),
("LAMBDA(x;x+1)(2)", "LAMBDA(x,x+1)(2)"),
] {
assert_eq!(input(src), expected, "{src}");
}
}
#[test]
fn spanish_input_preserves_reference_and_array_syntax() {
for (src, expected) in [
("SUMA({1\\2;3\\4};5)", "SUM({1,2;3,4},5)"),
("{VERDADERO\\FALSO;TRUE\\FALSE}", "{TRUE,FALSE;TRUE,FALSE}"),
("SUM((A1:A3;C1:C3))", "SUM((A1:A3,C1:C3))"),
("\"SUMA;FALSO\"&\"a\"\"b\"", "\"SUMA;FALSO\"&\"a\"\"b\""),
("'SUMA;FALSO'!A1+SUMA!B2+SUMA!FALSO", "'SUMA;FALSO'!A1+SUMA!B2+SUMA!FALSO"),
("SUMA[CONTAR.SI]+FALSO[SI]+CONTAR", "SUMA[CONTAR.SI]+FALSO[SI]+CONTAR"),
] {
assert_eq!(input(src), expected, "{src}");
}
for bad in [r"SUMA({1;2\3})", "{SUMA(A1)}", "{FALSO;}"] {
assert!(parse_input(bad, FormulaLocale::Es, |_| false).is_err(), "{bad}");
}
assert_eq!(print(&parse("SUMA(1,2)+FALSO").unwrap()), "SUMA(1,2)+FALSO");
assert!(parse("SUM(1;2)").is_err());
assert!(parse("{VERDADERO}").is_err());
// Pratt parsing can produce a deep left-hand AST without deep parser recursion.
let long_chain = "FALSO+".repeat(300) + "VERDADERO";
assert!(parse_input(&long_chain, FormulaLocale::Es, |_| false).is_err());
assert!(print_input(&parse(&long_chain).unwrap(), FormulaLocale::Es, |_| false).is_err());
}
#[test]
fn spanish_input_preserves_defined_and_local_names() {
let known = |name: &str| ["SUMA", "FALSO"].iter().any(|n| n.eq_ignore_ascii_case(name));
let expr = parse_input("SUMA(2;3)+FALSO+SI(VERDADERO;4;5)", FormulaLocale::Es, known).unwrap();
assert_eq!(print(&expr), "SUMA(2,3)+FALSO+IF(TRUE,4,5)");
for (src, expected) in [
("LET(FALSO;7;FALSO)", "LET(FALSO,7,FALSO)"),
("LET(falso;FALSO;SI(falso;1;2))", "LET(falso,FALSE,IF(falso,1,2))"),
("LET(SUMA;LAMBDA(x;x+1);SUMA(2))", "LET(SUMA,LAMBDA(x,x+1),SUMA(2))"),
("LET(falso;7;valor;FALSO;valor)", "LET(falso,7,valor,FALSO,valor)"),
("LAMBDA(FALSO;FALSO)(7)", "LAMBDA(FALSO,FALSO)(7)"),
("LAMBDA(SUMA;SUMA(2))(LAMBDA(x;x+1))", "LAMBDA(SUMA,SUMA(2))(LAMBDA(x,x+1))"),
("LET(FALSO;7;FALSO)+FALSO", "LET(FALSO,7,FALSO)+FALSE"),
("LET(FALSO;7;LAMBDA(x;FALSO+x)(1))", "LET(FALSO,7,LAMBDA(x,FALSO+x)(1))"),
] {
assert_eq!(input(src), expected, "{src}");
}
}
#[test]
fn locale_controls_decimal_list_array_separators_and_display() {
let es = FormulaLocale::Es;
let en = FormulaLocale::En;
let expr = parse_input("SI(VERDADERO;SUMA(1,5;2,25);0)", es, |_| false).unwrap();
assert_eq!(print(&expr), "IF(TRUE,SUM(1.5,2.25),0)");
assert_eq!(print_input(&expr, es, |_| false).unwrap(), "SI(VERDADERO;SUMA(1,5;2,25);0)");
assert_eq!(print_input(&expr, en, |_| false).unwrap(), print(&expr));
assert!(parse_input("SUM(1;2)", en, |_| false).is_err());
assert_eq!(print(&parse_input("SUMA(1,2)+FALSO", en, |_| false).unwrap()), "SUMA(1,2)+FALSO");
assert!(parse_input("SUMA(1.5;2)", es, |_| false).is_err());
let expr = parse("SUM({1.5,2.25;3,4})+SUM((A1:A3,C1:C3))").unwrap();
let shown = print_input(&expr, es, |_| false).unwrap();
assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr));
let expr = parse("LET(SUMA,LAMBDA(x,x+1),SUM(SUMA(2),FALSE))").unwrap();
let shown = print_input(&expr, es, |n| n == "FALSO").unwrap();
assert_eq!(shown, "LET(SUMA;LAMBDA(x;x+1);SUM(SUMA(2);FALSE))");
assert_eq!(print(&parse_input(&shown, es, |n| n == "FALSO").unwrap()), print(&expr));
}
#[test]
fn spanish_display_round_trips_text_containing_separators() {
let es = FormulaLocale::Es;
let expr = parse(r#"IF(A1="a,b;c",CONCAT("1.5",",",";"),SUM(0.25,A1:A3 B2))"#).unwrap();
let shown = print_input(&expr, es, |_| false).unwrap();
assert_eq!(shown, r#"SI(A1="a,b;c";CONCAT("1.5";",";";");SUMA(0,25;A1:A3 B2))"#);
assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr));
}
+5 -3
View File
@@ -3,7 +3,7 @@
use std::cmp::Ordering;
use gridcraft_core::parse::parse_number_text;
use gridcraft_core::{CellError, Value, compare_text};
use gridcraft_core::{CellError, Value, compare_numbers, compare_text};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Op {
@@ -113,7 +113,7 @@ impl Criterion {
_ => None,
};
match cell {
Some(x) => cmp_op(self.op, x.partial_cmp(n).unwrap_or(Ordering::Equal)),
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
None => self.op == Op::Ne,
}
}
@@ -252,7 +252,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
compare_text(p, t) == Ordering::Equal
}
}
(Value::Number(a), Value::Number(b)) => a == b,
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
(Value::Empty, Value::Empty) => true,
@@ -294,6 +294,8 @@ mod tests {
assert!(!m("<b", Value::Number(1.0)));
assert!(m("#N/A", Value::Error(CellError::NA)));
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(m("<>a*", Value::from("bcd")));
assert!(m(">1/1/2020", Value::Number(44000.0)));
}
+44 -3
View File
@@ -1,6 +1,6 @@
//! Date and time functions.
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd};
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd};
use gridcraft_core::parse::parse_input;
use gridcraft_core::{CellError, DateSystem, Value};
@@ -164,7 +164,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if !(1900..=9999).contains(&y) {
return Err(CellError::Num);
}
let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64);
let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64);
num_val(ymd_serial(sys, y, mo, dd)? as f64)
}
@@ -177,7 +177,7 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if !(1900..=9999).contains(&y) {
return Err(CellError::Num);
}
num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64)
num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64)
}
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
@@ -652,6 +652,36 @@ mod tests {
is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num);
}
#[test]
fn fictitious_1900_02_29() {
// Excel's 1900 calendar has 29 February 1900 = serial 60, and DATE counts day and month
// overflow through it.
assert_eq!(d(1900.0, 2.0, 29.0), 60.0);
assert_eq!(d(1900.0, 3.0, 0.0), 60.0);
assert_eq!(d(1900.0, 2.0, 28.0), 59.0);
assert_eq!(d(1900.0, 3.0, 1.0), 61.0);
assert_eq!(d(1900.0, 2.0, 30.0), 61.0);
assert_eq!(d(1900.0, 1.0, 60.0), 60.0);
assert_eq!(d(1900.0, 4.0, -31.0), 60.0);
assert_eq!(d(1901.0, -10.0, 29.0), 60.0);
assert_eq!(d(0.0, 2.0, 29.0), 60.0);
assert_eq!(d(1900.0, 1.0, 0.0), 0.0);
close(ev("DAY", vec![n(d(1900.0, 3.0, 0.0))]), 29.0);
close(ev("DATEVALUE", vec![t("1900-02-29")]), 60.0);
close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0);
close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0);
is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value);
// EDATE and EOMONTH land on it as the end of February 1900.
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0);
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0);
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0);
close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0);
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0);
close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0));
close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
}
#[test]
fn datedif_units() {
let s = n(d(2001.0, 6.0, 1.0));
@@ -755,5 +785,16 @@ mod tests {
close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0);
let spec = crate::lookup("YEAR").unwrap();
close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0);
// No fictitious day here: 1904 is a real leap year and 1900 is before the epoch.
let spec = crate::lookup("DATE").unwrap();
close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0);
close(crate::call(spec, &[n(1904.0), n(3.0), n(0.0)], &mut ctx), 59.0);
close(crate::call(spec, &[n(1904.0), n(3.0), n(1.0)], &mut ctx), 60.0);
is_err(crate::call(spec, &[n(1900.0), n(2.0), n(29.0)], &mut ctx), CellError::Num);
let spec = crate::lookup("EOMONTH").unwrap();
close(crate::call(spec, &[n(31.0), n(0.0)], &mut ctx), 59.0);
let spec = crate::lookup("DATEVALUE").unwrap();
close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0);
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
}
}
+4 -3
View File
@@ -487,7 +487,7 @@ fn integer_args(a: &[Arg]) -> R<Vec<u64>> {
v.iter()
.map(|x| {
let t = x.trunc();
if t < 0.0 || t >= 9.007_199_254_740_992e15 { Err(CellError::Num) } else { Ok(t as u64) }
if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) }
})
.collect()
}
@@ -1006,8 +1006,9 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(range.iter().filter(|v| c.matches(v)).count() as f64)
}
/// SUMIF / AVERAGEIF: matched numbers from the sum range, which takes the criteria range's
/// shape anchored at its own top-left.
/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions.
/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell;
/// the evaluator does that resizing (it has the references), so cells line up one to one here.
fn if_numbers(a: &[Arg]) -> R<Vec<f64>> {
let range = as_array(&crate::util::arg(a, 0)?.value);
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
+3 -2
View File
@@ -1185,7 +1185,7 @@ fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 1)?;
let s1 = num(a, 2)?.trunc();
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
if n < 0.0 || n > 1e15 || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
return Err(CellError::Num);
}
if s2 - s1 > 10_000.0 {
@@ -1632,7 +1632,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
"Population variance counting text as 0 and logicals as 1/0.",
|a: &[Arg], _c: C| num_val(var_p(&numbers_a(a)?)?)
),
f!("MIN", 1, VAR, Statistical, A, "MIN(number1, [number2], ...)", "Smallest number (0 when there are none).", |a: &[Arg], _c: C| num_val(
f!("MIN", 0, VAR, Statistical, A, "MIN(number1, [number2], ...)", "Smallest number (0 when there are none).", |a: &[Arg], _c: C| num_val(
min_of(&numbers(a)?)
)),
f!(
@@ -2290,6 +2290,7 @@ mod tests {
fn min_max_order() {
close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0);
close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0);
close(ev("MIN", vec![]), 0.0);
close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0);
close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0);
close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0);
+6 -2
View File
@@ -51,7 +51,7 @@ pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> {
/// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`).
pub(crate) fn has(args: &[Arg], i: usize) -> bool {
matches!(args.get(i), Some(a) if !(matches!(a.value, Value::Empty) && !a.from_ref))
matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref)
}
/// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses.
@@ -235,7 +235,9 @@ pub(crate) fn round_half_away(x: f64, digits: i32) -> f64 {
// value = 0.d1d2…d15 × 10^(e+1); keep the first `keep` digits.
let keep = e + 1 + digits;
if keep >= d.len() as i64 {
return x;
// Nothing to round off, but Excel still keeps only 15 significant digits, which drops binary noise
// such as 0.1 + 0.2 = 0.30000000000000004.
return to_sig_digits(x, d.len());
}
if keep < 0 {
return 0.0;
@@ -416,6 +418,8 @@ mod tests {
assert_eq!(round_half_away(1234.5678, -2), 1200.0);
assert_eq!(round_half_away(0.285, 2), 0.29);
assert_eq!(round_half_away(1.005, 2), 1.01);
assert_eq!(round_half_away(0.1 + 0.2, 15), 0.3);
assert_eq!(round_half_away(0.1 + 0.2, 20), 0.3);
}
#[test]
+2 -6
View File
@@ -34,12 +34,8 @@ impl Headless {
}
fn execute(&mut self, id: &str, params: Value) -> Result<Value, String> {
let r = self.session.execute(id, params).map_err(|e| e.to_string());
// Programmatic calls never open dialogs; drop anything a command queued for a UI.
for req in self.session.take_ui_requests() {
log::debug!("headless: ignoring UI request {req:?}");
}
r
// Programmatic calls never open dialogs; anything a command queued for a UI is dropped.
self.session.execute_headless(id, params).map_err(|e| e.to_string())
}
fn commands(&self, params: &Value) -> Value {
+11
View File
@@ -381,3 +381,14 @@ fn remote_backend_over_tcp() {
handle.join().unwrap();
assert!(Remote::connect("127.0.0.1:1").is_err());
}
#[test]
fn file_commands_without_a_file_fail() {
let mut s = server();
// `file.save` on Book1, which has no file yet, would ask for one too.
for command in ["file.open", "file.saveAs", "file.save", "data.getData", "data.fromTextCsv"] {
let msg = fails(&mut s, "execute_command", json!({"command": command}));
assert!(msg.contains("missing") && msg.contains("never open dialogs"), "{command}: {msg}");
fails(&mut s, "execute_command", json!({"command": command, "params": {}}));
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open_workbook",
"Open workbook",
"Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.",
"Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.",
obj(json!({"path": string("File path")}), &["path"]),
false,
),
+18
View File
@@ -42,6 +42,16 @@ impl Formula {
}
}
/// A static picture stored as cell content, independent of floating drawing anchors.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellPicture {
/// Encoded PNG/JPEG bytes, base64 in JSON. Sheet entries share the payload through Arc.
#[serde(with = "crate::b64")]
pub data: Vec<u8>,
pub mime: String,
pub alt: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Cell {
/// Constant value, or the cached result of the formula.
@@ -75,4 +85,12 @@ impl Cell {
v => v.display(),
}
}
/// What the formula bar shows when not editing: [`input_text`](Self::input_text), with a
/// legacy array formula in braces (`{=A1:A2*2}`).
pub fn bar_text(&self) -> String {
match &self.formula {
Some(f) if f.array.is_some() => format!("{{={}}}", f.text),
_ => self.input_text(),
}
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Serde helpers for maps keyed by `CellRef`. JSON object keys must be strings, so the keys are
//! written as `A1`-style text.
use std::collections::BTreeMap;
use gridcraft_core::CellRef;
use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer, V: Serialize>(map: &BTreeMap<CellRef, V>, s: S) -> Result<S::Ok, S::Error> {
s.collect_map(map.iter().map(|(k, v)| (k.a1(), v)))
}
pub fn deserialize<'de, D: Deserializer<'de>, V: Deserialize<'de>>(d: D) -> Result<BTreeMap<CellRef, V>, D::Error> {
let raw = BTreeMap::<String, V>::deserialize(d)?;
let mut out = BTreeMap::new();
for (k, v) in raw {
let cell = CellRef::parse(&k).ok_or_else(|| D::Error::custom(format!("invalid cell reference `{k}`")))?;
out.insert(cell, v);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use crate::{Comment, Hyperlink, Sheet};
use gridcraft_core::CellRef;
#[test]
fn comments_and_hyperlinks_survive_json() {
let mut s = Sheet::new("S");
let a1 = CellRef::new(0, 0);
let c3 = CellRef::new(2, 2);
s.comments.insert(
a1,
Comment {
author: "Example".into(),
text: "note".into(),
replies: vec![("B".into(), "r".into())],
threaded: true,
resolved: false,
visible: false,
},
);
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });
let json = serde_json::to_string(&s).expect("serialize");
let back: Sheet = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.comments, s.comments);
assert_eq!(back.hyperlinks, s.hyperlinks);
}
}
+21 -1
View File
@@ -290,6 +290,20 @@ pub struct Hyperlink {
// ---------------------------------------------------------------- drawing objects
/// How a floating object is pinned to the grid when its cells move or resize. Mirrors Excel's
/// Format ▸ Size and Properties ▸ Properties, stored in the file as the anchor's `editAs`:
/// `twoCell` (absent `editAs`), `oneCell` and `absolute`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AnchorMode {
/// Move and size with cells (Excel's default; `<xdr:twoCellAnchor>`).
#[default]
MoveAndSize,
/// Move but don't size with cells (`<xdr:twoCellAnchor editAs="oneCell">` / `<xdr:oneCellAnchor>`).
MoveOnly,
/// Don't move or size with cells (`editAs="absolute"` / `<xdr:absoluteAnchor>`).
Absolute,
}
/// Position of a floating object: top-left cell plus offset, size in points.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
@@ -298,6 +312,9 @@ pub struct Anchor {
pub dy: f32,
pub width: f32,
pub height: f32,
/// How the object follows its anchor cell when rows/columns move (sort, insert, delete).
#[serde(default)]
pub mode: AnchorMode,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -340,7 +357,7 @@ pub enum LegendPos {
Right,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Series {
/// Formula text for the series name (`Sheet1!$B$1`) or a literal.
pub name: Option<String>,
@@ -352,6 +369,9 @@ pub struct Series {
/// Combo charts: this series drawn as a line on the secondary axis.
pub secondary: bool,
pub kind: Option<ChartKind>,
/// Draw line segments as a smoothed (spline) curve.
#[serde(default)]
pub smooth: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+2 -1
View File
@@ -6,13 +6,14 @@
mod b64;
pub mod cell;
mod cellmap;
pub mod features;
pub mod sheet;
pub mod store;
pub mod style;
pub mod workbook;
pub use cell::{Cell, Formula};
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
pub use store::CellStore;
+44 -2
View File
@@ -1,11 +1,12 @@
//! Worksheets.
use std::collections::BTreeMap;
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value};
use serde::{Deserialize, Serialize};
use crate::cell::Cell;
use crate::cell::{Cell, CellPicture};
use crate::features::*;
use crate::store::CellStore;
use crate::style::{Color, StyleId};
@@ -22,6 +23,9 @@ pub enum Visibility {
pub struct Sheet {
pub name: String,
pub cells: CellStore,
/// Static pictures are sparse rich content. Ordinary cells carry no picture pointer.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "picture_map")]
pub cell_pictures: BTreeMap<CellRef, Arc<CellPicture>>,
/// Values spilled by dynamic-array formulas (maintained by calc), keyed by cell.
#[serde(skip)]
pub spill: BTreeMap<CellRef, Value>,
@@ -48,7 +52,9 @@ pub struct Sheet {
pub validations: Vec<Validation>,
pub tables: Vec<Table>,
pub autofilter: Option<AutoFilter>,
#[serde(with = "crate::cellmap")]
pub comments: BTreeMap<CellRef, Comment>,
#[serde(with = "crate::cellmap")]
pub hyperlinks: BTreeMap<CellRef, Hyperlink>,
pub charts: Vec<Chart>,
pub images: Vec<Image>,
@@ -73,6 +79,7 @@ impl Sheet {
Sheet {
name: name.into(),
cells: CellStore::new(),
cell_pictures: BTreeMap::new(),
spill: BTreeMap::new(),
spill_ranges: BTreeMap::new(),
cols: BTreeMap::new(),
@@ -134,7 +141,30 @@ impl Sheet {
}
pub fn set_value(&mut self, c: CellRef, v: Value) {
let style = self.style_id(c);
self.cells.set(c, Cell { value: v, formula: None, style });
self.set_cell(c, Cell { value: v, formula: None, style });
}
/// Replaces content, removing any picture at this address. Style-only edits use set_style.
pub fn set_cell(&mut self, c: CellRef, cell: Cell) {
self.cell_pictures.remove(&c);
self.cells.set(c, cell);
}
pub fn remove_cell(&mut self, c: CellRef) -> Option<Cell> {
self.cell_pictures.remove(&c);
self.cells.remove(c)
}
/// Removes content in a range; returns scalar cells, with pictures removed from the side map.
pub fn take_cells(&mut self, range: RangeRef) -> Vec<(CellRef, Cell)> {
self.cell_pictures.retain(|c, _| !range.contains(*c));
self.cells.take_range(range)
}
/// Pictures retain a scalar fallback for calculation and unsupported consumers.
pub fn set_picture(&mut self, c: CellRef, picture: Arc<CellPicture>) {
let style = self.style_id(c);
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style });
self.cell_pictures.insert(c, picture);
}
pub fn input_text(&self, c: CellRef) -> String {
if self.cell_pictures.contains_key(&c) { String::new() } else { self.cell(c).map(Cell::input_text).unwrap_or_default() }
}
pub fn set_style(&mut self, c: CellRef, style: StyleId) {
let mut cell = self.cells.get(c).cloned().unwrap_or_default();
@@ -339,3 +369,15 @@ mod tests {
assert_eq!(s.col_left(2), 128.0);
}
}
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
mod picture_map {
use super::*;
use serde::{Deserializer, Serializer};
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
map.iter().collect::<Vec<_>>().serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<BTreeMap<CellRef, Arc<CellPicture>>, D::Error> {
Ok(Vec::<(CellRef, Arc<CellPicture>)>::deserialize(deserializer)?.into_iter().collect())
}
}
+48
View File
@@ -120,6 +120,40 @@ impl CellStore {
pub fn has(&self, c: CellRef) -> bool {
self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some())
}
/// Positions whose cells differ between `self` and `other` (stored on one side only, or not
/// equal). Bands both stores share are skipped, so comparing an edited copy with the store
/// it was cloned from only visits the bands the edit touched.
pub fn diff(&self, other: &CellStore) -> Vec<CellRef> {
let empty = Band::default();
let mut keys: Vec<u32> = self.bands.keys().chain(other.bands.keys()).copied().collect();
keys.sort_unstable();
keys.dedup();
let mut out = Vec::new();
for k in keys {
let (a, b) = (self.bands.get(&k), other.bands.get(&k));
if let (Some(a), Some(b)) = (a, b)
&& Arc::ptr_eq(a, b)
{
continue;
}
let (a, b) = (a.map_or(&empty, |x| x), b.map_or(&empty, |x| x));
for (row, cols) in &a.rows {
for (col, cell) in cols {
if b.rows.get(row).and_then(|r| r.get(col)) != Some(cell) {
out.push(CellRef::new(*row, *col));
}
}
}
for (row, cols) in &b.rows {
for col in cols.keys() {
if !a.rows.get(row).is_some_and(|r| r.contains_key(col)) {
out.push(CellRef::new(*row, *col));
}
}
}
}
out
}
/// Removes every cell in `r` and returns them.
pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> {
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
@@ -210,6 +244,20 @@ mod tests {
assert_eq!(s.get(c(5, 0)).unwrap().value, Value::Number(99.0));
}
#[test]
fn diff_visits_changed_bands() {
let mut s = CellStore::new();
for r in 0..1000 {
s.set(c(r, 0), Cell::value((r as f64).into()));
}
let snap = s.clone();
assert!(s.diff(&snap).is_empty());
s.set(c(5, 0), Cell::value(99.0.into()));
s.set(c(700, 3), Cell::value("new".into()));
s.remove(c(900, 0));
assert_eq!(snap.diff(&s), [c(5, 0), c(700, 3), c(900, 0)]);
}
#[test]
fn range_iteration_order() {
let mut s = CellStore::new();
+63
View File
@@ -3,8 +3,10 @@
use std::sync::Arc;
use gridcraft_core::DateSystem;
use gridcraft_formula::Expr;
use serde::{Deserialize, Serialize};
use crate::cell::Formula;
use crate::sheet::Sheet;
use crate::style::{StyleTable, Theme};
@@ -143,6 +145,53 @@ impl Workbook {
.find(|n| n.scope == Some(sheet) && n.name.eq_ignore_ascii_case(name))
.or_else(|| self.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(name)))
}
/// Spells calls of LAMBDA names the way the names are defined (`Double(4)`, where the
/// parser gives `DOUBLE(4)`: it upper-cases function names, as built-in ones are shown).
pub fn name_call_case(&self, e: Expr) -> Expr {
spell_calls(e, &self.lambda_names())
}
/// [`Workbook::name_call_case`] for every cell formula and defined name (after reading a
/// file).
pub fn apply_name_call_case(&mut self) {
let lambdas = self.lambda_names();
if lambdas.is_empty() {
return;
}
for sheet in self.sheets.iter_mut() {
let fixed: Vec<_> = sheet
.cells
.iter()
.filter_map(|(c, cell)| {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
let ne = spell_calls(e.clone(), &lambdas);
(ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) }))
})
.collect();
if fixed.is_empty() {
continue;
}
let sheet = Arc::make_mut(sheet);
for (c, f) in fixed {
if let Some(cell) = sheet.cells.get_mut(c) {
cell.formula = Some(Arc::new(f));
}
}
}
for n in self.names.iter_mut() {
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
let ne = spell_calls(e.clone(), &lambdas);
if ne != e {
n.formula = gridcraft_formula::print(&ne);
}
}
}
}
/// Names of the defined names holding a LAMBDA (callable like functions).
fn lambda_names(&self) -> Vec<String> {
let is_lambda = |f: &str| f.trim_start_matches('=').trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("LAMBDA("));
self.names.iter().filter(|n| is_lambda(&n.formula)).map(|n| n.name.clone()).collect()
}
/// Table by name across sheets: (sheet index, table index).
pub fn table(&self, name: &str) -> Option<(usize, usize)> {
for (si, s) in self.sheets.iter().enumerate() {
@@ -172,6 +221,20 @@ impl Workbook {
}
}
/// Calls of the names in `lambdas` spelled as the names are.
fn spell_calls(e: Expr, lambdas: &[String]) -> Expr {
if lambdas.is_empty() {
return e;
}
e.map(&mut |x| match x {
Expr::Call(n, args) => match lambdas.iter().find(|l| l.eq_ignore_ascii_case(&n)) {
Some(l) => Expr::Call(l.clone(), args),
None => Expr::Call(n, args),
},
other => other,
})
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -58,7 +58,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
/// Renders a date/time. `None` when the serial is outside the supported range.
pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out> {
if !v.is_finite() || v < 0.0 || v >= 2_958_466.0 {
if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) {
return None;
}
let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3);
+8
View File
@@ -20,6 +20,14 @@ image = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The desktop's preferred language for the first-run interface language.
sys-locale = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Read the browser's preferred language for the first-run web interface language.
web-sys = { version = "0.3", features = ["Window", "Navigator"] }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
+11 -1
View File
@@ -1,7 +1,7 @@
//! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG.
//!
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark]
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt]
//! [--dialog 'name={json}'] out.png`
use gridcraft_engine::Session;
@@ -20,6 +20,7 @@ fn main() {
let mut pane: Option<String> = None;
let mut chart: Option<u32> = None;
let mut dialog: Option<String> = None;
let mut language: Option<String> = None;
let mut out = "snapshot.png".to_string();
let mut i = 0;
while i < args.len() {
@@ -65,6 +66,10 @@ fn main() {
i += 1;
dialog = args.get(i).cloned();
}
"--language" => {
i += 1;
language = args.get(i).cloned();
}
other => out = other.to_string(),
}
i += 1;
@@ -93,6 +98,11 @@ fn main() {
{
let mut app = SheetApp::new(session, Default::default());
app.ui.dark = dark;
if let Some(code) = &language
&& let Some(l) = gridcraft_ui_egui::i18n::Language::parse(code)
{
app.ui.language = l;
}
if let Some(t) = tab {
app.ui.ribbon_tab = t;
}
+82
View File
@@ -0,0 +1,82 @@
//! Original border diagrams, painted with the same strokes as worksheet cells.
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use gridcraft_engine::model::{BorderStyle, Borders, Theme};
use crate::grid::paint_border;
use crate::theme::{self, Tokens};
/// A labelled menu button with a two-by-two diagram to distinguish inside edges
/// from the selection outline. Labels stay available to accessibility tools.
pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response {
let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0)));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
let t = Tokens::get(ui.ctx());
let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0));
let edges = [
[rect.left_top(), rect.right_top()],
[rect.left_bottom(), rect.right_bottom()],
[rect.left_top(), rect.left_bottom()],
[rect.right_top(), rect.right_bottom()],
[rect.left_center(), rect.right_center()],
[rect.center_top(), rect.center_bottom()],
];
use BorderStyle::{Double, None, Thick, Thin};
let styles = match preset {
"bottom" => [None, Thin, None, None, None, None],
"top" => [Thin, None, None, None, None, None],
"left" => [None, None, Thin, None, None, None],
"right" => [None, None, None, Thin, None, None],
"all" => [Thin; 6],
"outside" => [Thin, Thin, Thin, Thin, None, None],
"thickOutside" => [Thick, Thick, Thick, Thick, None, None],
"doubleBottom" => [None, Double, None, None, None, None],
"thickBottom" => [None, Thick, None, None, None, None],
"topBottom" => [Thin, Thin, None, None, None, None],
"topThickBottom" => [Thin, Thick, None, None, None, None],
"topDoubleBottom" => [Thin, Double, None, None, None, None],
"inside" => [None, None, None, None, Thin, Thin],
_ => [None; 6],
};
for (points, style) in edges.into_iter().zip(styles) {
if style == None {
ui.painter().extend(egui::Shape::dotted_line(&points, t.text_disabled, 3.0, 0.45));
} else {
paint_border(ui.painter(), points, style, t.text);
}
}
ui.painter().text(pos2(response.rect.left() + 36.0, response.rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(12.5), t.text);
response
}
/// Preview only reads the dialog draft. A white worksheet surface keeps automatic
/// and explicit border colors faithful in both light and dark application themes.
pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) {
ui.add_space(8.0);
ui.label("Preview");
let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview"));
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(area, 4.0, t.grid_bg);
ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let rect = area.shrink2(vec2(30.0, 20.0));
let edges = [
(borders.top, [rect.left_top(), rect.right_top()]),
(borders.bottom, [rect.left_bottom(), rect.right_bottom()]),
(borders.left, [rect.left_top(), rect.left_bottom()]),
(borders.right, [rect.right_top(), rect.right_bottom()]),
(borders.diag_down, [rect.left_top(), rect.right_bottom()]),
(borders.diag_up, [rect.left_bottom(), rect.right_top()]),
];
for (line, points) in edges.iter().take(4) {
if line.is_none() {
ui.painter().extend(egui::Shape::dotted_line(points, Color32::from_gray(190), 4.0, 0.5));
}
}
for (line, points) in edges {
if !line.is_none() {
let color = line.color.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK);
paint_border(ui.painter(), points, line.style, color);
}
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Cell pictures use the current cell geometry, independent of floating objects.
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::{Arc, Mutex, Weak};
use egui::{Color32, Painter, Rect, TextureHandle, pos2, vec2};
use gridcraft_engine::model::CellPicture;
const MAX_ENCODED: usize = 16 * 1024 * 1024;
const MAX_PIXELS: u64 = 16_000_000;
const MAX_CACHE_BYTES: usize = 64 * 1024 * 1024;
// The byte budget controls texture memory; sparse entries have a separate modest bound.
const MAX_CACHE_ITEMS: usize = 4096;
#[derive(Default)]
struct Cache {
entries: HashMap<usize, Entry>,
bytes: usize,
}
struct Entry {
// Holding a Weak prevents this allocation address from being reused. The
// immutable Arc is the content identity: copy/undo share it; replacement
// creates another allocation. No source bytes are cloned or hashed per frame.
source: Weak<CellPicture>,
texture: Option<TextureHandle>,
bytes: usize,
used: u64,
}
impl Cache {
fn texture(&mut self, ctx: &egui::Context, picture: &Arc<CellPicture>) -> Option<TextureHandle> {
let frame = ctx.cumulative_frame_nr();
let key = Arc::as_ptr(picture) as usize;
if let Some(entry) = self.entries.get_mut(&key) {
entry.used = frame;
return entry.texture.clone();
}
// Prune discarded workbooks/payloads before allocating another texture.
self.entries.retain(|_, entry| {
if entry.source.strong_count() == 0 {
self.bytes = self.bytes.saturating_sub(entry.bytes);
false
} else {
true
}
});
// Read only the header before admission. Never decode an image just to evict it
// again while painting a larger visible set on the next frame.
let source = dimensions(picture);
let estimate = source.map_or(0, |(_, (w, h))| w as usize * h as usize * 4);
while self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(estimate) > MAX_CACHE_BYTES {
let oldest = self.entries.iter().filter(|(_, e)| e.used.saturating_add(1) < frame).min_by_key(|(_, e)| e.used).map(|(key, _)| *key);
let Some(oldest) = oldest else {
// Keep visible images stable under pressure; show the placeholder for
// excess content instead of re-decoding it each frame.
return None;
};
if let Some(entry) = self.entries.remove(&oldest) {
self.bytes = self.bytes.saturating_sub(entry.bytes);
}
}
let decoded = source.and_then(|(format, _)| decode_format(picture, format));
let bytes = decoded.as_ref().map_or(0, |image| image.pixels.len() * 4);
let texture = decoded.map(|image| ctx.load_texture("cell picture", image, egui::TextureOptions::LINEAR));
self.bytes += bytes;
self.entries.insert(key, Entry { source: Arc::downgrade(picture), texture: texture.clone(), bytes, used: frame });
texture
}
}
fn dimensions(picture: &CellPicture) -> Option<(image::ImageFormat, (u32, u32))> {
if picture.data.len() > MAX_ENCODED {
return None;
}
let format = image::guess_format(&picture.data).ok()?;
if !matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg) {
return None;
}
let dimensions = image::ImageReader::with_format(Cursor::new(&picture.data), format).into_dimensions().ok()?;
if dimensions.0 == 0
|| dimensions.1 == 0
|| dimensions.0 > 8192
|| dimensions.1 > 8192
|| u64::from(dimensions.0) * u64::from(dimensions.1) > MAX_PIXELS
{
return None;
}
Some((format, dimensions))
}
#[cfg(test)]
fn decode(picture: &CellPicture) -> Option<egui::ColorImage> {
let (format, _) = dimensions(picture)?;
decode_format(picture, format)
}
fn decode_format(picture: &CellPicture, format: image::ImageFormat) -> Option<egui::ColorImage> {
let mut reader = image::ImageReader::with_format(Cursor::new(&picture.data), format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let rgba = reader.decode().ok()?.into_rgba8();
Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()))
}
fn fitted_rect(cell: Rect, size: [usize; 2]) -> Option<Rect> {
if cell.width() <= 0.0 || cell.height() <= 0.0 || size.contains(&0) {
return None;
}
let source = vec2(size[0] as f32, size[1] as f32);
let scale = (cell.width() / source.x).min(cell.height() / source.y);
Some(Rect::from_center_size(cell.center(), source * scale))
}
pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
if cell.width() <= 0.0 || cell.height() <= 0.0 || !cell.intersects(painter.clip_rect()) {
return;
}
// Context-local handles must never leak between offscreen, native or browser
// egui contexts, even when a workbook shares its Arc payload with another view.
let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.cell_pictures")).clone());
let texture = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).texture(painter.ctx(), picture);
let painter = painter.with_clip_rect(cell.intersect(painter.clip_rect()));
if let Some(texture) = texture {
if let Some(rect) = fitted_rect(cell, texture.size()) {
painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
} else {
crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn picture(color: [u8; 4]) -> Result<Arc<CellPicture>, image::ImageError> {
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba(color));
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, image::ImageFormat::Png)?;
Ok(Arc::new(CellPicture { data: bytes.into_inner(), mime: "image/png".into(), alt: "Test picture".into() }))
}
#[test]
fn aspect_fit_and_bounded_content_cache() -> Result<(), Box<dyn std::error::Error>> {
let cell = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 100.0));
assert_eq!(fitted_rect(cell, [200, 100]), Some(Rect::from_min_size(pos2(10.0, 45.0), vec2(100.0, 50.0))));
assert_eq!(fitted_rect(cell, [100, 200]), Some(Rect::from_min_size(pos2(35.0, 20.0), vec2(50.0, 100.0))));
assert!(fitted_rect(Rect::NOTHING, [200, 100]).is_none());
let ctx = egui::Context::default();
let mut cache = Cache::default();
let red = picture([255, 0, 0, 255])?;
let blue = picture([0, 0, 255, 255])?;
// Distinct content with identical dimensions must receive distinct handles.
let red_id = cache.texture(&ctx, &red).ok_or("missing red texture")?.id();
assert_eq!(cache.texture(&ctx, &red).ok_or("missing red texture")?.id(), red_id);
assert_ne!(cache.texture(&ctx, &blue).ok_or("missing blue texture")?.id(), red_id);
let payloads: Vec<_> = (0..133).map(|_| picture([0, 0, 255, 255])).collect::<Result<_, _>>()?;
for payload in &payloads {
cache.texture(&ctx, payload);
}
let ids: Vec<_> =
payloads.iter().map(|p| cache.texture(&ctx, p).ok_or("missing visible texture").map(|t| t.id())).collect::<Result<_, _>>()?;
for _ in 0..3 {
let mut output = ctx.run_ui(Default::default(), |_| {
for (payload, id) in payloads.iter().zip(&ids) {
assert_eq!(cache.texture(&ctx, payload).map(|t| t.id()), Some(*id));
}
});
// This test observes handles without a renderer consuming their upload deltas.
output.textures_delta.clear();
}
assert!(cache.entries.len() <= MAX_CACHE_ITEMS);
assert!(cache.bytes <= MAX_CACHE_BYTES);
assert!(decode(&CellPicture { data: vec![], mime: "image/png".into(), alt: String::new() }).is_none());
assert_eq!(decode(&red).ok_or("decode failed")?.pixels.first(), Some(&Color32::RED));
Ok(())
}
}
+5 -2
View File
@@ -180,7 +180,7 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
mesh
}
fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
let x = geo.x(sh, a.cell.col) + a.dx * geo.z;
let y = geo.y(sh, a.cell.row) + a.dy * geo.z;
Rect::from_min_size(pos2(x, y), vec2(a.width * geo.z, a.height.max(1.0) * geo.z))
@@ -243,7 +243,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
}
}
}
if !sp.text.is_empty() {
if !sp.text.is_empty() && !app.editing_text_box(sp.id) {
let job = egui::text::LayoutJob::simple(
sp.text.clone(),
FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())),
@@ -254,6 +254,9 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 };
p.galley(pos, g, Color32::BLACK);
}
if sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
selection_frame(p, r);
}
}
for im in &sh.images {
let r = anchor_rect(geo, sh, &im.anchor);
+54 -11
View File
@@ -2,7 +2,7 @@
//!
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}` — run any engine or UI command
//! - `engine.commands` — every command with enablement and params docs
//! - `engine.commands {search?}` — every command with enablement and params docs
//! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state
//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, grid geometry, perf)
//! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`,
@@ -144,6 +144,17 @@ fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m:
app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]);
}
/// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all.
fn filter_commands(commands: Vec<Json>, search: Option<&str>) -> Vec<Json> {
let Some(q) = search.map(str::to_ascii_lowercase) else { return commands };
let has = |c: &Json, k: &str| c.get(k).and_then(Json::as_str).is_some_and(|v| v.to_ascii_lowercase().contains(&q));
let in_menu = |c: &Json| {
let menu = c.get("menu").and_then(Json::as_array);
menu.is_some_and(|m| m.iter().filter_map(Json::as_str).any(|v| v.to_ascii_lowercase().contains(&q)))
};
commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect()
}
pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome {
let s = |k: &str| p.get(k).and_then(Json::as_str);
let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32;
@@ -155,17 +166,21 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
}
"engine.commands" => {
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label) in [
("ui.ribbonTab", "Ribbon Tab"),
("view.formulaBar", "Formula Bar"),
("view.collapseRibbon", "Collapse Ribbon"),
("view.darkMode", "Dark Mode"),
("view.zoom100", "100%"),
("ui.dialog", "Open Dialog"),
for (id, label, params) in [
("ui.ribbonTab", "Ribbon Tab", ""),
("view.formulaBar", "Formula Bar", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.zoom100", "100%", ""),
("ui.dialog", "Open Dialog", ""),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
] {
v.push(json!({"id": id, "label": label, "enabled": true, "ui": true}));
v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true}));
}
ok(Json::Array(v))
ok(Json::Array(filter_commands(v, s("search"))))
}
"engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::<Vec<_>>())),
"document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(app.session.run(method, p.clone())),
@@ -178,6 +193,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
"ui.set" => {
if let Some(v) = p.get("dark").and_then(Json::as_bool) {
app.ui.dark = v;
app.ui.system_theme = false;
}
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
app.ui.formula_bar = v;
@@ -403,7 +419,7 @@ pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) {
if now < *deadline {
return true;
}
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use `gridcraft-cli snapshot`"}));
let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"}));
false
});
}
@@ -428,3 +444,30 @@ pub fn save_image(image: &egui::ColorImage, path: Option<&str>) -> Json {
None => json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Json> {
vec![
json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}),
json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}),
json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}),
]
}
fn ids(v: &[Json]) -> Vec<&str> {
v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect()
}
#[test]
fn search_filters_case_insensitively() {
assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]);
assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border")));
assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]);
assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]);
assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty());
assert_eq!(filter_commands(sample(), None).len(), 3);
}
}
+159 -27
View File
@@ -31,6 +31,8 @@ pub struct Dialog {
pub search: String,
pub list_index: usize,
pub result: Option<Json>,
picture_inbox: Option<crate::Inbox>,
picture_target: Option<(u64, usize)>,
}
impl Dialog {
@@ -49,6 +51,8 @@ impl Dialog {
search: String::new(),
list_index: 0,
result: None,
picture_inbox: None,
picture_target: None,
}
}
@@ -63,6 +67,8 @@ impl Dialog {
search: String::new(),
list_index: 0,
result: None,
picture_inbox: None,
picture_target: None,
}
}
@@ -188,10 +194,10 @@ impl Dialog {
label: "Paste",
options: vec![
("all", "All"),
("formulas", "Formulas"),
("values", "Values"),
("formats", "Formats"),
("comments", "Comments and notes"),
("formulas", "Formulas (F)"),
("values", "Values (V)"),
("formats", "Formats (T)"),
("comments", "Comments and notes (C)"),
("validation", "Validation"),
("allExceptBorders", "All except borders"),
("columnWidths", "Column widths"),
@@ -205,7 +211,7 @@ impl Dialog {
options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")],
},
Check { key: "skipBlanks", label: "Skip blanks" },
Check { key: "transpose", label: "Transpose" },
Check { key: "transpose", label: "Transpose (E)" },
Check { key: "link", label: "Paste Link" },
],
json!({"what": "all", "operation": "none"}),
@@ -548,7 +554,18 @@ impl Dialog {
],
json!({"chart": p("chart")}),
),
"insertPicture" => Dialog::custom("insertPicture", "Insert Picture", json!({"path": ""})),
"insertPicture" => {
let placement = params.get("placement").and_then(Json::as_str).unwrap_or("overCells");
let mut dialog = Dialog::form(
"insertPicture",
if placement == "cell" { "Place Picture in Cell" } else { "Place Picture over Cells" },
"insert.picture",
vec![],
json!({"path": "", "placement": placement, "at": active}),
);
dialog.picture_target = app.view_key();
dialog
}
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})),
@@ -632,6 +649,9 @@ fn build_params(d: &Dialog) -> Json {
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
if d.name == "pasteSpecial" {
paste_special_shortcuts(&mut d, ctx);
}
let mut open = true;
let mut win_open = true;
let mut confirm = false;
@@ -641,14 +661,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
_ => 380.0,
};
egui::Window::new(d.title.clone())
let window = egui::Window::new(d.title.clone())
.id(egui::Id::new("dialog").with(&d.name))
.collapsible(false)
.resizable(false)
.default_width(width)
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
.open(&mut win_open)
.show(ctx, |ui| {
.open(&mut win_open);
let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window };
window.show(ctx, |ui| {
ui.set_min_width(width - 20.0);
match d.name.as_str() {
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
@@ -687,20 +708,55 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
});
}
"insertPicture" => {
ui.label("Picture file path (PNG or JPEG):");
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
ui.text_edit_singleline(&mut path);
d.values.insert("path".into(), json!(path));
if let Some(pick) = &app.services.pick_open
&& ui.button("Browse…").clicked()
&& let Some(p) = pick()
{
d.values.insert("path".into(), json!(p));
// A private inbox dies with this dialog; late browser results
// cannot affect a later picture choice or another workbook.
let arrived = d.picture_inbox.as_ref().and_then(|inbox| {
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop()
});
if let Some((name, bytes)) = arrived {
d.picture_inbox = None;
if bytes.len() > 16 * 1024 * 1024 {
app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into()));
} else {
d.values.remove("path");
d.values.insert("alt".into(), json!(name));
d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes)));
}
}
if app.services.pick_picture_async.is_some() {
let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected");
ui.label(name);
} else {
ui.label("Picture file path (PNG or JPEG):");
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
if ui.text_edit_singleline(&mut path).changed() {
d.values.remove("base64");
d.values.insert("path".into(), json!(path));
}
}
if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() {
if let Some(start) = &app.services.pick_picture_async {
let inbox = crate::Inbox::default();
start(inbox.clone());
d.picture_inbox = Some(inbox);
} else if let Some(pick) = &app.services.pick_picture
&& let Some(path) = pick()
{
d.values.remove("base64");
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
d.values.insert("alt".into(), json!(alt));
d.values.insert("path".into(), json!(path));
}
}
ui.label("Description (optional):");
let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string();
if ui.text_edit_singleline(&mut alt).changed() {
d.values.insert("alt".into(), json!(alt));
}
if d.values.get("placement").and_then(Json::as_str) == Some("cell") {
ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small());
}
ok_cancel(ui, &mut confirm, &mut open);
if confirm {
app.run_or_alert("insert.picture", json!({"path": d.values.get("path")}));
}
}
"pickList" => {
let items: Vec<String> = app
@@ -814,6 +870,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open {
open = false;
}
if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() {
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
confirm = false;
}
if confirm && let Some(cmd) = d.command {
let params = build_params(&d);
match app.run(cmd, params) {
@@ -828,6 +888,71 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
}
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if ctx.text_edit_focused() {
return;
}
let right_alt = ctx.input(|i| {
i.key_down(Key::AltRight)
|| i.events.iter().any(|event| {
matches!(event, egui::Event::Key { key: Key::AltRight, .. })
|| matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. })
})
});
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
let mnemonic = |key| match key {
Key::T => Some('t'),
Key::V => Some('v'),
Key::F => Some('f'),
Key::C => Some('c'),
Key::E => Some('e'),
_ => None,
};
let mut text_to_consume = Vec::new();
for event in ctx.input(|i| i.events.clone()) {
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
if !allowed(modifiers) {
continue;
}
let Some(letter) = mnemonic(key) else { continue };
text_to_consume.push(letter);
if repeat {
continue;
}
if key == Key::E {
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
d.values.insert("transpose".into(), json!(!transpose));
} else {
let what = match key {
Key::T => "formats",
Key::V => "values",
Key::F => "formulas",
_ => "comments",
};
d.values.insert("what".into(), json!(what));
}
}
// Browsers can deliver Text before or after Key. Act only on Key, then remove
// its matching text so one physical press cannot toggle Transpose twice.
ctx.input_mut(|i| {
i.events.retain(|event| match event {
egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()),
egui::Event::Text(text) if text.len() == 1 => {
let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase();
if let Some(index) = text_to_consume.iter().position(|&c| c == letter) {
text_to_consume.remove(index);
false
} else {
true
}
}
_ => true,
});
});
}
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -1087,12 +1212,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
(BorderStyle::Dotted, "dotted"),
(BorderStyle::Double, "double"),
] {
if ui.selectable_label(slot.style == s, label).clicked() {
let response = ui.selectable_label(slot.style == s, label);
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
if response.clicked() {
slot.style = s;
}
}
});
}
let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default();
crate::border_preview::sample(ui, &st.borders, &theme);
}
"Fill" => {
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
@@ -1135,20 +1265,21 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date"));
r.request_focus();
});
let locale = app.ui.language.formula_locale();
let list = gridcraft_engine::cmd::formulas::function_list();
let q = d.search.to_ascii_lowercase();
let hits: Vec<&Json> = list
.iter()
.filter(|f| {
q.is_empty()
|| f["name"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q))
|| f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q))
|| f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q))
})
.take(300)
.collect();
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (i, f) in hits.iter().enumerate() {
let name = f["name"].as_str().unwrap_or("");
let name = locale.function_name(f["name"].as_str().unwrap_or(""));
let r = ui.add(
egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or("")))
.min_size(vec2(480.0, 20.0)),
@@ -1164,12 +1295,13 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
});
if let Some(f) = hits.get(d.list_index) {
ui.separator();
ui.label(egui::RichText::new(f["signature"].as_str().unwrap_or("")).strong());
ui.label(f["description"].as_str().unwrap_or(""));
ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong());
ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default());
}
let mut open = true;
ok_cancel(ui, confirm, &mut open);
if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n);
let cur = app.editor.as_ref().map(|e| e.text.clone());
let text = match cur {
Some(t) if t.starts_with('=') => format!("{t}{n}("),
@@ -1368,7 +1500,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(n["scope"].as_str().unwrap_or(""));
if ui.small_button("Delete").clicked() {
app.run_or_alert("formulas.deleteName", json!({"name": n["name"]}));
app.run_or_alert("formulas.deleteName", json!({"name": n["name"], "scope": n["scope"]}));
}
});
ui.end_row();
+6 -2
View File
@@ -19,6 +19,8 @@ pub const REF_COLORS: [Color32; 8] = [
#[derive(Clone, Debug)]
pub struct EditState {
pub sheet: usize,
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
pub locale: gridcraft_engine::formula::FormulaLocale,
pub cell: CellRef,
pub text: String,
/// Started by typing (Enter mode: arrows commit) rather than F2 (Edit mode: arrows move the caret).
@@ -46,6 +48,7 @@ impl EditState {
let caret = text.chars().count();
EditState {
sheet,
locale: Default::default(),
cell,
text,
enter_mode,
@@ -163,7 +166,8 @@ impl EditState {
continue;
}
match c {
')' => depth += 1,
')' | '}' | ']' => depth += 1,
'{' | '[' => depth -= 1,
'(' => {
if depth == 0 {
let name: String = chars[..i]
@@ -181,7 +185,7 @@ impl EditState {
}
depth -= 1;
}
',' if depth == 0 => arg += 1,
c if c == self.locale.list_separator() && depth == 0 => arg += 1,
_ => {}
}
}
+28 -11
View File
@@ -65,19 +65,31 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
fn active_input(app: &SheetApp) -> String {
let Some(d) = app.session.active() else { return String::new() };
let Some(sh) = d.wb.active() else { return String::new() };
let a = d.selection.active;
let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
if let Some(picture) = sh.cell_pictures.get(&a) {
return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) };
}
if let Some(c) = sh.cell(a) {
if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
return String::new();
}
return c.input_text();
return if let Some(f) = &c.formula {
let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet);
// A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does.
if f.array.is_some() { format!("{{{shown}}}") } else { shown }
} else {
c.input_text()
};
}
// A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly.
for (anchor, r) in &sh.spill_ranges {
if r.contains(a)
&& let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref())
{
return format!("={}", f.text);
if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) {
return String::new();
}
return crate::formula_locale::display(&format!("={}", f.text), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet);
}
}
String::new()
@@ -166,7 +178,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
ed.caret_to_end = false;
ed.sync_caret = true;
}
if !ed.autocomplete.is_empty() && (tab || (enter && false)) {
if !ed.autocomplete.is_empty() && tab {
ed.accept_autocomplete();
} else if enter {
action = Some((if shift { -1 } else { 1 }, 0, false, false));
@@ -293,7 +305,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text);
}
ed.point = None;
let names: Vec<String> = function_names();
let names: Vec<String> = function_names()
.iter()
.map(|name| {
app.session.active().map(|d| crate::formula_locale::completion_name(name, ed.locale, &d.wb, ed.sheet)).unwrap_or(name).to_string()
})
.collect();
ed.update_autocomplete(&names);
app.session.mode = if ed.can_point() {
gridcraft_engine::Mode::Point
@@ -334,11 +351,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
if r.clicked() {
pick = Some(i);
}
if sel && let Some(desc) = function_description(n) {
if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale) {
r.on_hover_text(desc);
}
}
if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| function_description(n)) {
if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) {
ui.separator();
ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap());
}
@@ -348,20 +365,20 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
ed.request_focus = true;
}
} else if let Some((name, arg)) = ed.current_function()
&& let Some(sig) = function_signature(&name)
&& let Some(sig) = crate::formula_locale::signature(&name, ed.locale)
{
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let (head, rest) = sig.split_once('(').unwrap_or((&sig, ""));
ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0)));
ui.label(egui::RichText::new(format!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0)));
let inner = rest.trim_end_matches(')');
let parts: Vec<&str> = inner.split(", ").collect();
let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect();
for (i, part) in parts.iter().enumerate() {
let is_cur = i == arg.min(parts.len().saturating_sub(1)) || (part.contains("...") && arg >= i);
let txt = egui::RichText::new(*part).font(if is_cur { theme::ui_bold(12.0) } else { theme::ui_font(12.0) });
ui.label(txt);
if i + 1 < parts.len() {
ui.label(egui::RichText::new(", ").font(theme::ui_font(12.0)));
ui.label(egui::RichText::new(format!("{} ", ed.locale.list_separator())).font(theme::ui_font(12.0)));
}
}
ui.label(egui::RichText::new(")").font(theme::ui_font(12.0)));
+106
View File
@@ -0,0 +1,106 @@
//! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English.
use gridcraft_engine::formula::{self, FormulaLocale, ParseError};
use gridcraft_engine::model::Workbook;
pub(crate) fn canonical(
text: &str,
locale: FormulaLocale,
wb: &Workbook,
sheet: usize,
cell: gridcraft_engine::core::CellRef,
) -> Result<String, ParseError> {
if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") {
return Ok(text.to_string());
}
// Same formula detection as the engine's `input_to_cell` (no trimming: " =1" is text).
let body = text;
if body.starts_with('=')
|| ((body.starts_with('+') || body.starts_with('-')) && body.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('))
{
let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some();
// Match the engine's existing convenience for missing closing parentheses.
let mut body = body.to_string();
let mut expr = formula::parse_input(&body, locale, known);
for _ in 0..8 {
if expr.is_ok() {
break;
}
body.push(')');
expr = formula::parse_input(&body, locale, known);
}
return Ok(format!("={}", formula::print(&expr?)));
}
// Decimal-comma number entry follows the same preference, without rewriting text or dates.
let normalized = body.trim().replace(locale.decimal_separator(), ".");
let number = normalized.strip_suffix('%').unwrap_or(&normalized);
if number.parse::<f64>().is_ok_and(f64::is_finite) {
return Ok(normalized);
}
Ok(text.to_string())
}
pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String {
// English (and every language without a formula table) shows the stored text untouched,
// and skips a parse per frame.
if locale == FormulaLocale::En {
return text.to_string();
}
let Some(body) = text.strip_prefix('=') else { return text.to_string() };
formula::parse(body)
.and_then(|expr| formula::print_input(&expr, locale, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some()))
.map(|body| format!("={body}"))
.unwrap_or_else(|_| text.to_string())
}
// Descriptions/signatures for the supported Spanish function table; untranslated functions
// retain their English help. Parameter punctuation follows the same locale as entry.
const SPANISH_HELP: &[(&str, &str, &str)] = &[
("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."),
("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."),
("IF", "SI(prueba_lógica; valor_si_verdadero; [valor_si_falso])", "Devuelve un valor según una condición."),
("COUNT", "CONTAR(valor1; [valor2]; ...)", "Cuenta las celdas que contienen números."),
("SUMIF", "SUMAR.SI(rango; criterio; [rango_suma])", "Suma las celdas que cumplen un criterio."),
("COUNTIF", "CONTAR.SI(rango; criterio)", "Cuenta las celdas que cumplen un criterio."),
("VLOOKUP", "BUSCARV(valor_buscado; matriz; indicador_columnas; [ordenado])", "Busca un valor en la primera columna de una tabla."),
("TODAY", "HOY()", "Devuelve la fecha actual."),
("TRUE", "VERDADERO()", "Devuelve el valor lógico VERDADERO."),
("FALSE", "FALSO()", "Devuelve el valor lógico FALSO."),
];
pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option<String> {
let canonical = locale.canonical_name(name);
if locale == FormulaLocale::Es
&& let Some((_, signature, _)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical)
{
return Some((*signature).into());
}
crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string()))
}
pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option<String> {
let canonical = locale.canonical_name(name);
if locale == FormulaLocale::Es
&& let Some((_, _, description)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical)
{
return Some((*description).into());
}
crate::formula_bar::function_description(canonical)
}
/// Avoid suggesting an alias that would call a workbook/table name instead of the function.
pub(crate) fn completion_name<'a>(name: &'a str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> &'a str {
let alias = locale.function_name(name);
if wb.name(alias, sheet).is_some() || wb.table(alias).is_some() { name } else { alias }
}
/// Engine-generated edit templates use canonical syntax. The only incomplete template is
/// the bare function opening emitted by Insert Function; typed user input never enters here.
pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String {
if let Some(name) = text.strip_prefix('=').and_then(|s| s.strip_suffix('('))
&& !name.is_empty()
&& name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
{
return format!("={}(", completion_name(name, locale, wb, sheet));
}
display(text, locale, wb, sheet)
}
+226 -109
View File
@@ -312,7 +312,8 @@ fn display_text(
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let rect = ui.available_rect_before_wrap();
let available = ui.available_rect_before_wrap();
let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), available.bottom()));
let resp = ui.allocate_rect(rect, Sense::click_and_drag());
app.grid.rect = Some(rect);
let Some(d) = app.session.active() else { return };
@@ -323,7 +324,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let mut view = app.view();
// Scrolling.
let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
if resp.hovered() || resp.contains_pointer() {
if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) {
if zoom_delta != 1.0 {
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
let _ = app.session.run("view.zoom", json!({"percent": z}));
@@ -373,7 +374,6 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, t.grid_bg);
let mut cf = CfCache::new();
let char_w = ui.fonts_mut(|f| f.glyph_width(&FontId::new(11.0 * 96.0 / 72.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), '0')).max(1.0);
// Quadrants: (rows, cols, clip).
let (sr0, sr1) = geo.scroll_rows(sh);
let (sc0, sc1) = geo.scroll_cols(sh);
@@ -395,7 +395,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
continue;
}
let p = painter.with_clip_rect(*clip);
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, char_w, &mut cf, &t);
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t);
paint_selection(&p, &geo, sh, &sel, &t, app);
}
// Freeze lines.
@@ -416,33 +416,74 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
}
// Interaction.
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
interact(app, ui, &resp, &geo, sh, &wb);
crate::text_box::show(app, ui, &geo, sh);
if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
in_cell_editor(app, ui, &geo, sh, &wb);
}
filter_menu(app, ui, &geo);
list_picker(app, ui, &geo);
header_menu(app, ui);
filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu);
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
context_menu(app, ui);
vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t);
if app.session.clipboard.is_some() {
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
}
}
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, right: f32, limit: f32, t: &Tokens) {
let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, geo.cells.bottom()));
if track.height() <= 0.0 || track.width() <= 0.0 {
return;
}
let visible = track.height() / geo.z;
let scroll = app.view().scroll.y;
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32;
let extent = (content - visible).max(scroll).max(0.0).min(limit);
let id = ui.id().with("vscroll");
let resp = ui.interact(track, id, Sense::click_and_drag());
let drag_id = id.with("extent");
if resp.drag_started() {
ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
}
let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height());
let travel = track.height() - thumb_h;
let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h));
ui.painter().rect_filled(track, 0.0, t.tab_bar);
ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
let mut next = scroll;
if resp.dragged() && travel > 0.0 {
next += resp.drag_delta().y / travel * extent;
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
if p.y < thumb.top() {
next -= visible;
} else if p.y > thumb.bottom() {
next += visible;
}
}
if resp.hovered() {
next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z;
}
next = next.clamp(0.0, limit);
if next != scroll {
if let Some(v) = app.view_mut() {
v.scroll.y = next;
}
ui.ctx().request_repaint();
}
}
#[allow(clippy::too_many_arguments)]
fn paint_quadrant(
p: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
rows: (u32, u32),
cols: (u32, u32),
char_w: f32,
cf: &mut CfCache,
t: &Tokens,
) {
fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) {
let z = geo.z;
let (r0, r1) = rows;
let (c0, c1) = cols;
@@ -504,6 +545,10 @@ fn paint_quadrant(
// Text and per-cell decorations.
for (c, rect, lk, cfl) in &looks {
let c = *c;
if let Some(picture) = sh.cell_pictures.get(&c) {
crate::cell_pictures::paint(p, *rect, picture);
continue;
}
let v = sh.value(c);
if let Some(l) = cfl
&& let Some((frac, rgb, gradient)) = l.bar
@@ -560,8 +605,13 @@ fn paint_quadrant(
let size = st.font.size * 96.0 / 72.0 * z;
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
let font = FontId::new(size, fam);
// Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns).
let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0);
let avail_w = rect.right() - text_left;
let (text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w);
let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w);
if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) {
text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si);
}
if text.is_empty() {
continue;
}
@@ -577,7 +627,9 @@ fn paint_quadrant(
};
let indent = st.align.indent as f32 * 9.0 * z;
let pad = 2.5 * z;
if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap {
let rotated = st.align.rotation != 0 && st.align.rotation != 255;
// Rotated text wraps along its own direction (below), not across the column width.
if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) {
let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0);
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w);
job.halign = match halign {
@@ -622,9 +674,9 @@ fn paint_quadrant(
galley = p.layout_no_wrap(s, font.clone(), color);
w = galley.size().x;
}
// Overflow into empty neighbours for text.
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
let mut clip = *rect;
if !numeric && !matches!(v, Value::Number(_)) && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
let mut right = rect.right();
let mut left = rect.left();
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
@@ -667,9 +719,23 @@ fn paint_quadrant(
VAlign::Bottom => rect.bottom() - gh - 0.5 * z,
};
let cp = p.with_clip_rect(clip.intersect(p.clip_rect()));
if st.align.rotation != 0 && st.align.rotation != 255 {
if rotated {
let angle = -(st.align.rotation as f32).to_radians();
let mut shape = egui::epaint::TextShape::new(pos2(rect.left() + pad, rect.bottom() - pad), galley.clone(), color);
// Upright (±90°) wrapped text wraps to the row height: lines then stack across the column.
let galley = if st.align.wrap && st.align.rotation.abs() == 90 {
let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
p.layout_job(job)
} else {
galley.clone()
};
// Upward text grows up and right from the bottom-left corner; downward text grows down
// and left from its anchor, so that anchor sits one block-height in from the top-left.
let pos = if angle > 0.0 {
pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad)
} else {
pos2(rect.left() + pad, rect.bottom() - pad)
};
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
shape.angle = angle;
cp.add(shape);
} else {
@@ -769,7 +835,7 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
}
fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
let w = style.width();
let (a, b) = (pts[0], pts[1]);
match style {
@@ -982,7 +1048,13 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel
// ------------------------------------------------------------------ interaction
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() {
return;
}
let ctx = ui.ctx().clone();
// Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates.
let primary_clicked = resp.clicked_by(egui::PointerButton::Primary);
let pointer = ctx.input(|i| i.pointer.clone());
let mods = ctx.input(|i| i.modifiers);
let pos = pointer.interact_pos().or(pointer.hover_pos());
@@ -1006,6 +1078,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
ctx.set_cursor_icon(CursorIcon::ResizeRow);
} else if handle.contains(p) && app.editor.is_none() {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.grid.drag == Drag::None && !pointer.any_down() {
resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range.");
}
} else if in_cells && app.editor.is_none() && on_border(cur_rect, p) {
ctx.set_cursor_icon(CursorIcon::Move);
} else if in_cells {
@@ -1048,7 +1123,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
return;
}
if app.session.draw_tool == "eraser" && (resp.clicked() || resp.dragged()) {
if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) {
if let Some(p) = pos
&& let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p)
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink)
@@ -1059,11 +1134,18 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
// Objects (charts, pictures, shapes) take clicks first.
if resp.drag_started() || resp.clicked() {
if resp.drag_started() || primary_clicked {
if let Some(p) = pos
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_chart = Some(id);
if kind == "shape"
&& resp.double_clicked()
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox)
{
app.begin_text_box_edit(id);
return;
}
if kind == "chart" && resp.double_clicked() {
app.grid.pane = Some("formatChart".into());
}
@@ -1075,13 +1157,39 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
return;
} else if resp.clicked() || resp.drag_started() {
} else if primary_clicked || resp.drag_started() {
app.selected_chart = None;
}
}
// A double-click is also a click: handle header AutoFit before resize setup returns.
if resp.double_clicked()
&& let Some(p) = pos
{
let autofit = if in_col_header {
col_edge(geo, sh, p.x).map(|(col, _)| {
let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col);
("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) })
})
} else if in_row_header {
row_edge(geo, sh, p.y).map(|(row, _)| {
let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row);
("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) })
})
} else {
None
};
if let Some((command, params)) = autofit {
app.grid.drag = Drag::None;
app.grid.drag_select = false;
app.run_or_alert(command, params);
resp.request_focus();
return;
}
}
// Press: decide the drag mode.
if resp.drag_started() || (resp.clicked() && app.grid.drag == Drag::None) {
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
// Where the press began (a drag is only recognised after the pointer has moved).
let Some(p) = pointer.press_origin().or(pos) else { return };
if in_corner {
@@ -1140,6 +1248,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
ed.point_cell = Some(c);
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
@@ -1177,12 +1290,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()}));
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if resp.clicked() && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
}
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& resp.clicked()
&& primary_clicked
&& !mods.any()
{
let r = geo.cell_rect(sh, c);
@@ -1193,6 +1306,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
// The first recognised drag already includes movement away from the press.
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
@@ -1221,26 +1340,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
);
match app.grid.drag.clone() {
Drag::Select => {
if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_cell {
let r = RangeRef::new(start, pc);
let text = if r.is_single() { r.start.a1() } else { r.a1() };
let keep = ed.point_cell;
ed.insert_ref(&text);
ed.point_cell = keep;
}
} else {
let anchor = sel.anchor;
let mut ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
ranges.pop();
ranges.push(RangeRef::new(anchor, pc).a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": sel.active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
}
select_to_pointer(app, geo, sh, wb, p);
scroll_by(app, auto);
}
Drag::Rows(anchor) => {
@@ -1313,6 +1413,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Release.
if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) {
match std::mem::take(&mut app.grid.drag) {
Drag::Select if resp.drag_stopped() => {
if let Some(p) = pos {
select_to_pointer(app, geo, sh, wb, p);
}
}
Drag::Fill { target: Some(t) } => {
let src = sel.current();
if t != src {
@@ -1334,29 +1439,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.grid.drag_select = false;
}
// Double-click on header edges autofits.
// Double-click the fill handle.
if resp.double_clicked()
&& let Some(p) = pos
&& handle.contains(p)
{
if in_col_header && let Some((c, _)) = col_edge(geo, sh, p.x) {
let _ = app.run("home.autofitColumnWidth", json!({"cols": RangeRef::cols(c, c).a1()}));
} else if in_row_header && let Some((r, _)) = row_edge(geo, sh, p.y) {
let _ = app.run("home.autofitRowHeight", json!({"rows": format!("{}:{}", r + 1, r + 1)}));
} else if handle.contains(p) {
// Double-click the fill handle: fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let mut last = src.end.row;
while last + 1 < MAX_ROWS
&& sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty())
&& last - src.end.row < 1_000_000
{
last += 1;
}
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
// Fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let mut last = src.end.row;
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
{
last += 1;
}
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
}
@@ -1386,12 +1484,41 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
if resp.clicked() || resp.drag_started() {
if primary_clicked || resp.drag_started() {
resp.request_focus();
}
keyboard(app, &ctx, resp, geo, sh);
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
);
if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_cell {
let range = RangeRef::new(start, cell);
let text = if range.is_single() { range.start.a1() } else { range.a1() };
let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
ed.insert_ref(&text);
ed.point_cell = Some(start);
}
} else if let Some(d) = app.session.active() {
// Read the current anchor: starting a drag may have just replaced the selection.
let anchor = d.selection.anchor;
let active = d.selection.active;
let mut ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
ranges.pop();
ranges.push(RangeRef::new(anchor, cell).a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
}
}
fn coalesce_last_undo(app: &mut SheetApp) {
// Merge consecutive drag steps into one undo entry.
if let Some(d) = app.session.active_mut()
@@ -1503,12 +1630,13 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
/// Grid keyboard handling when no text field has focus.
fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
// Focus left on an editor that no longer exists goes back to the grid.
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar")];
if app.editor.is_none() && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()];
let editing = app.editor.is_some() || app.text_box_editor.is_some();
if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
resp.request_focus();
}
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && !(app.editor.is_none() && stale.contains(&f)));
if app.editor.is_some() || app.dialog.is_some() || other_focus || app.message.is_some() {
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
if editing || app.dialog.is_some() || other_focus || app.message.is_some() {
return;
}
if ctx.memory(|m| m.focused()).is_none() {
@@ -1523,18 +1651,10 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
for ev in events {
match ev {
egui::Event::Copy => {
if let Ok(r) = app.run("edit.copy", json!({}))
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
{
ctx.copy_text(t.to_string());
}
app.copy_to_clipboard(ctx, "edit.copy");
}
egui::Event::Cut => {
if let Ok(r) = app.run("edit.cut", json!({}))
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
{
ctx.copy_text(t.to_string());
}
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})),
egui::Event::Text(text) => {
@@ -1643,7 +1763,13 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0);
}
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
/// Test the popup itself, not whether the pointer is over any part of the app.
/// A trigger can sit outside its popup, so its opening click must be ignored.
fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool {
response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere())
}
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
let Some((col, at)) = app.grid.filter_menu else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
@@ -1676,9 +1802,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
}
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
let key = egui::Id::new(("filter_sel", col));
let mut close = false;
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
area.show(ui.ctx(), |ui| {
let response = area.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(230.0);
if ui.button("↑ Sort Ascending").clicked() {
@@ -1691,7 +1818,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
}
ui.separator();
ui.label(egui::RichText::new("Filter").strong());
let key = egui::Id::new(("filter_sel", col));
let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::<Vec<(String, bool)>>(key)).unwrap_or(values.clone());
let all = checks.iter().all(|(_, b)| *b);
let mut all_new = all;
@@ -1710,7 +1836,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
ui.horizontal(|ui| {
if ui.button("Clear Filter").clicked() {
let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true}));
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
close = true;
}
if ui.button("Apply").clicked() {
@@ -1722,13 +1847,13 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
json!({"column": col_to_letters(col), "values": vals, "blanks": blanks})
};
let _ = app.run("data.filterBy", p);
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
close = true;
}
});
});
});
if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
if close || popup_dismissed(&response.response, was_open) {
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
app.grid.filter_menu = None;
}
}
@@ -1790,11 +1915,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
}
}
"edit.copy" | "edit.cut" => {
if let Ok(r) = app.run(id, json!({}))
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
{
ui.ctx().copy_text(t.to_string());
}
app.copy_to_clipboard(ui.ctx(), id);
}
other => app.run_or_alert(other, json!({})),
}
@@ -1939,7 +2060,7 @@ fn paint_overlays(
let _ = (wb, si);
}
fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
let Some(c) = app.grid.list_picker else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
@@ -1952,7 +2073,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv);
let r = geo.cell_rect(sh, c);
let mut close = false;
egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
ui.set_min_width(r.width().max(100.0));
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
@@ -1965,15 +2086,15 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
});
});
});
if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
if close || popup_dismissed(&response.response, was_open) {
app.grid.list_picker = None;
}
}
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
let Some((at, rows)) = app.grid.header_menu else { return };
let mut close = false;
egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
let items: Vec<(&str, &str)> = if rows {
@@ -2021,11 +2142,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
"ui:rowHeight" => app.open_dialog("rowHeight", json!({})),
"ui:columnWidth" => app.open_dialog("columnWidth", json!({})),
"edit.copy" | "edit.cut" => {
if let Ok(r) = app.run(id, json!({}))
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
{
ui.ctx().copy_text(t.to_string());
}
app.copy_to_clipboard(ui.ctx(), id);
}
other => app.run_or_alert(other, json!({})),
}
@@ -2033,7 +2150,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
}
});
});
if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
if close || popup_dismissed(&response.response, was_open) {
app.grid.header_menu = None;
}
}

Some files were not shown because too many files have changed in this diff Show More