Compare commits

..
Author SHA1 Message Date
skylar 9eb2a3d68e ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:04 -06:00
Dylan M. TaylorandCodebuff d8c5f5885f refactor: reduce cognitive complexity in options, python highlighter, i18n scanner, loop search (#2611)
* refactor: split complex functions for Sonar S3776

Extract helpers from backstage options (general, calculation and iteration
sections), code_editor python (string scanning), xtask i18n literals
(escape and string readers) and calc recalc loops_among (SCC search).
No behaviour change.

* Restore the loop count that loops_among's refactor dropped

The S3776 split moved the loop-detection body into loops_among without
the 'let n = circ.len()' it reads, so calc failed to compile and the
Sonar build broke. Restore the binding from main.

* Extract the SCC pop step from components() to reduce cognitive complexity (S3776)

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-26 09:48:03 -04:00
aiongg f6b0cec67e Merge pull request #2619 from aiongg/g4
Window per workbook (4/4): two windows on one workbook without copying it; Arrange All
2026-10-26 00:00:24 -04:00
aiongg 862dfc1920 Merge pull request #2618 from aiongg/g3
Window per workbook (3/4): a native window per workbook window
2026-10-25 23:59:45 -04:00
aiongg 1b63c3f335 Merge pull request #2617 from aiongg/g2
Window per workbook (2/4): windows in the engine; New Window shares the workbook
2026-10-25 23:58:35 -04:00
aiongg ecb0e546d8 Merge pull request #2616 from aiongg/g1
Window per workbook (1/4): a cell draft stays in its workbook; Switch Windows
2026-10-25 23:57:56 -04:00
aiongg 1f2da4048d Merge g3 into g4 2026-10-26 03:36:29 +00:00
aiongg dbec5c5800 Merge g2 into g3 2026-10-26 03:36:18 +00:00
aiongg 57da3d4641 Merge g1 into g2 2026-10-26 03:35:47 +00:00
aiongg 380be36ac3 G1: the W, W key tip opens Switch Windows through its control 2026-10-26 03:33:34 +00:00
aiongg 5c250a7fea Merge g3 into g4 2026-10-26 03:29:08 +00:00
aiongg c7911c836a G3: craft-libs at storytold main b926b5c0 2026-10-26 03:28:46 +00:00
aiongg 0f5fc81a6c Merge g2 into g3 2026-10-26 03:27:27 +00:00
aiongg 0316765204 Merge g1 into g2 2026-10-26 03:16:42 +00:00
aiongg 320bcfcf54 G1: regenerate the key tip tests 2026-10-26 03:16:42 +00:00
aiongg 7fdceaddc6 Merge g3 into g4 2026-10-26 02:57:21 +00:00
aiongg ec56e80f7f Merge g2 into g3 2026-10-26 02:57:21 +00:00
aiongg b29f05a2c7 Merge g1 into g2 2026-10-26 02:57:21 +00:00
aiongg 5900247075 G1: Switch Windows draws its check with the chrome fallback when no face has ✓ 2026-10-26 02:56:59 +00:00
aiongg f102fe29a1 Merge g3 into g4
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/formulas.rs
#	crates/engine/src/cmd/inspect.rs
#	crates/engine/src/cmd/pivot.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/tabs.rs
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-26 02:45:41 +00:00
aiongg 7085aa1c47 G3: build on current main
Screenshots are admitted in logic for the root viewport and issued from its pass, so nothing
reads the focused native window any more; the field goes.
2026-10-26 02:45:24 +00:00
aiongg 94fcbf8b4c G3: craft-libs at e8b4dde5 (craft-libs#340)
craft-update, craft-window, craft-ui and craft-print all come from one craft-libs revision, the
head of storytold/craft-libs#340 on the aiongg fork, until #340 merges.
2026-10-26 02:37:42 +00:00
aiongg 015b9abe5b Merge g2 into g3
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	ROADMAP.md
#	apps/gridcraft/src/main.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-26 02:37:36 +00:00
aiongg 929495e274 Merge g1 into g2
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/extra.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	docs/control-protocol.md
#	docs/ui-parity.md
2026-10-26 02:37:33 +00:00
aiongg 22aec7e667 G1: build on current main
Scripts can't run the View tab's window commands (Switch Windows, New Window, Hide, Unhide):
they change the active workbook, as window.* steps do. The Switch Windows button names its
accessibility role with egui::Role (egui 0.37).
2026-10-26 02:37:27 +00:00
aiongg 903bece86f Merge upstream main into g1
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-26 02:23:11 +00:00
Dylan M. Taylor 42bbf6baa2 refactor: split complex functions for Sonar S3776 (#2612)
Extract group_bracket from outline layout and move_marks from data
outline_settings. No behaviour change.
2026-10-25 21:39:17 -04:00
Dylan M. Taylor 52cf825395 refactor: split complex functions for Sonar S3776 (#2609)
Extract helpers from auto_sum (selection and single-cell guess), update_cursor
(drag, hover, freeze, and header-edge cursors) and read_styles (dxf reader).
No behaviour change.
2026-10-25 21:37:09 -04:00
Dylan M. Taylor 6f0e43c21c Linux: default to XWayland for file drag-and-drop on Wayland desktops (#165)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:08 -04:00
Dylan M. Taylor 0870b1ad1c Merge pull request #2595 from storytold/chore/deps-safe-refresh
chore(deps): safe dependency refresh
2026-10-25 19:00:54 -04:00
skylar 14dd23db6c chore(deps): refresh serde_json, arboard, muda, winresource, sha2 2026-10-25 16:36:12 -06:00
Dylan M. Taylor ad10881cc3 build: pin craft-update to craft-libs main (495e597) (#2591)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:44 -04:00
Dylan M. TaylorandCodebuff ee7cbf32ab build: pin craft-update to craft-libs main (495e597) (#2586)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:15 -04:00
Dylan M. Taylor 2fbbed33bc Merge pull request #783 from vangel76/fix/control-auth-777
Security: the --control server requires an auth token and closes bad connections (#777)
2026-10-25 16:45:46 -04:00
Dylan M. Taylor 1a36d9db07 feat: startup update prompt modal and direct self-updating (#2566)
* feat: startup update prompt modal and direct self-updating

The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status message. An install holds back while the workbook is unsaved.

Ref: storytold/craft-libs#319

* build: refresh the lock

* build: pin craft-update to craft-libs main (db19c37)

* fix(updates): address review feedback

Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.

* fix(updates): add Check for Updates… to the native macOS Help menu
2026-10-25 15:37:52 -04:00
skylar taylor-barrick 41769f1d16 Migrate GridCraft to egui 0.37 (#2543)
* E37: migrate GridCraft to egui 0.37

Pin the reviewed fork and shared dependencies, adapt input and rendering to the
new frame lifecycle, and preserve bounded capture handling and font notices.
Add representative accessibility checks and meaningful control labels.

Validation: full repository CI passed all ten stages, with 2,249 tests passed,
zero failed and one existing timing test ignored. Native workflows and paired
performance qualification remain documented separately.

* egui037: record final published pins and local qualification

* M0.37: fix RTL clipping and preserve screenshot budgets

* test: share migrated GridCraft UI harness setup

* egui037: document migrated UI and regression contracts

* egui-037: align popup test fonts and avoid redundant tooltip probes
2026-10-25 14:55:00 -04:00
3b65b9af58 Sonar maintainability: explicit imports, method paths, smaller functions (#2412)
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix imports

* Fix serde imports

* Fix engine imports

* Fix clippy gate and a type error from the explicit-import change

Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Apply rustfmt to the explicit-import cleanup so the fmt gate passes

* Share the per-directory font path expansion in theme.rs

The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Read the Windows fonts folder in one place in theme.rs

Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Build the light and dark UI palettes from one table in theme.rs

Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Share the Noto CJK font list between the CJK script lists in theme.rs

The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Share Linux Noto directory list across Thai and Hebrew font lookups

Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.

* Reuse in_dirs for Thai and Hebrew Noto font candidates

Removes the remaining duplicated per-directory loops flagged by SonarCloud.

* Share one font-list builder for the Thai and Hebrew faces

The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 12:32:02 -04:00
Brandon ThomasandClaude Opus 5.5 4eb2fb5c8e Review fix for #2473: a repeated sheet name matches in any case
Sheet1!A1:SHEET1!B2 is a range whose second end repeats the sheet, as
Sheet1!A1:Sheet1!B2 is: the check uses same_name, Excel's case-insensitive
name comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:56:18 -04:00
Brandon Thomas 0665476b35 Merge pull request #2493 from Harrison-Booth:feat/options-iteration-fields
# Conflicts:
#	docs/function-parity.md
#	docs/ui-parity.md
2026-10-25 09:36:59 -04:00
Brandon Thomas 189c3cdb0f Merge pull request #2497 from marcoarnulfo:fix/full-column-extend-scroll 2026-10-25 09:36:38 -04:00
Brandon Thomas 674e1c05a3 Merge pull request #2481 from marcoarnulfo:fix/trace-arrows-stay-on-traced-cell 2026-10-25 09:36:37 -04:00
Brandon Thomas 0469b51c93 Merge pull request #2444 from OdooPudakMachinery:web-chrome-flag 2026-10-25 09:36:37 -04:00
Brandon Thomas 4a44dec500 Merge pull request #2515 from Fubeo:fub/edit-note-keeps-thread 2026-10-25 09:36:36 -04:00
Brandon Thomas 0f5227ddde Merge pull request #2513 from Fubeo:fub/keep-sheet-id 2026-10-25 09:36:35 -04:00
Brandon Thomas 97ec7653aa Merge pull request #2512 from Fubeo:fub/open-saved-without-recalc 2026-10-25 09:36:34 -04:00
Brandon Thomas 27e96a9a58 Merge pull request #2561 from arcanii:autosum-across-a-row 2026-10-25 09:36:34 -04:00
Brandon Thomas 2c81adf94f Merge pull request #2557 from Silvertree2010:fix/2517-a-new-font-derived-from-the-them 2026-10-25 09:36:33 -04:00
Brandon Thomas 9413c57e2c Merge pull request #2555 from Silvertree2010:fix/2407-xlsx-only-one-warning-when-sever 2026-10-25 09:36:32 -04:00
Brandon Thomas bd7b867a1f Merge pull request #2552 from Silvertree2010:fix/h2457-cli-eval-cat-print-huge-or-tiny 2026-10-25 09:36:31 -04:00
Brandon Thomas 80ea166d7d Merge pull request #2551 from Silvertree2010:fix/h2446-deleting-a-row-or-column-leaves 2026-10-25 09:36:31 -04:00
Brandon Thomas cb28634140 Merge pull request #2528 from Silvertree2010:fix/h2369-datetime-from-serial-returns-yea 2026-10-25 09:36:30 -04:00
Brandon Thomas 39a56ee902 Merge pull request #2524 from Silvertree2010:fix/2453-row-height-dialog-uses-pixels-sh 2026-10-25 09:36:29 -04:00
Brandon Thomas 0697abbbbc Merge pull request #2473 from Silvertree2010:fix/h2050-formula-with-a-sheet-qualified-r 2026-10-25 09:36:28 -04:00
Brandon Thomas 6208f15d29 Merge pull request #2463 from Silvertree2010:fix/h2062-yearfrac-basis-0-counts-the-last 2026-10-25 09:36:28 -04:00
Brandon Thomas 25d6a3a44f Merge pull request #2457 from Silvertree2010:fix/h1979-imsqrt-of-a-negative-real-return 2026-10-25 09:36:27 -04:00
Brandon Thomas 1871541948 Merge pull request #2443 from Silvertree2010:fix/h2018-isoweeknum-weeknum-21-return-wee 2026-10-25 09:36:26 -04:00
Brandon Thomas 990aa1315a Merge pull request #2428 from Silvertree2010:fix/h1934-minverse-returns-0-for-inverse-e 2026-10-25 09:36:25 -04:00
Brandon Thomas c9b9907c60 Merge pull request #2417 from Silvertree2010:fix/h1936-percentrank-percentrank-inc-perc 2026-10-25 09:36:24 -04:00
Brandon ThomasandClaude Opus 5.5 f86aec12da Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:18 -04:00
Brandon ThomasandClaude Opus 5.5 bca67944b8 macOS signing: keep the temporary keychain until the build has signed with it
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:00 -04:00
Brandon ThomasandClaude Opus 5.5 4995ecc66e Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:04:14 -04:00
Bryan MarkandClaude Opus 5.5 802c782360 AutoSum: a selected row gets its total to the right, as in Excel
AutoSum over any selection of more than one cell wrote a total under each column, so a row
selected (B2:E2) got four one-cell totals in row 3 instead of =SUM(B2:E2) in F2.

As Excel does, totals now go in the selection's empty last row or column, and otherwise past
it:
- a single row: its total in its last cell when that is empty, else just to its right;
- a block whose last column is empty: each row's total there; whose last row is empty: each
  column's; both: both, and the block's total in the corner;
- otherwise totals under each column, as before.

A whole-row selection still stops at the data's last column, and all totals are one undo step.
Tests: autosum_over_a_row_totals_it_to_the_right,
autosum_over_a_whole_row_totals_it_after_the_data and
autosum_fills_a_selections_empty_last_column_and_row; each fails without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:34:30 +08:00
NetterSilverandClaude Opus 5.5 85b6f564f9 Keep <scheme val="minor"/> on every body-font font on save
Fixes #2517

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:20:17 +01:00
NetterSilverandClaude Haiku 5.5 d07639c7c4 Warn for each skipped drawing object and chart on XLSX open
Fixes #2407

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:18 +01:00
NetterSilverandClaude Haiku 5.5 64a78a7210 Print huge and tiny CLI numbers in exponent form
Found while testing apps/gridcraft-cli/src/main.rs:196-199 (number_text, used by value_text and check.rs:627)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:44:39 +00:00
NetterSilverandClaude Haiku 5.5 80e2041159 Remove sparklines when their row or column is deleted
Found while testing crates/engine/src/cmd/sheet.rs:~199 (shift_sheet_features, sparklines loop)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:39:12 +00:00
NetterSilverandClaude Haiku 5.5 960c64c3e4 Return None from datetime_from_serial when rounding carries past 9999
Found while testing crates/core/src/date.rs:168-177

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:19:51 +00:00
NetterSilverandClaude Haiku 5.5 14e73dd0e9 Make the Row Height dialog use points like Excel
Fixes #2453

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 00:39:55 +00:00
Fubeo 4548bab2e3 feat(engine): open a saved workbook without recalculating it
DocState::new recalculates the whole workbook, so values saved in the file
change where nothing was edited (volatile functions, functions GridCraft
lacks, cycles). DocState::from_saved builds the dependency graph without
evaluating, so every value stays the file's until an edit reaches it.
2026-10-25 01:37:44 +02:00
Fubeo 3e6eac4222 fix(engine): editing a note or comment keeps its thread
Writing a note or comment on a cell that already has one of the same kind
replaced the whole thread: Edit Comment lost the replies, the resolved state
and the dates, and Edit Note the box read from the file. Now only the text
(and the author, when the command names one) changes, as Excel's Edit Note
does; a note written over a comment still replaces it.
2026-10-25 01:31:19 +02:00
Fubeo 85e8f7909e fix(xlsx): keep each sheet's sheetId across a rewrite
The writer numbers sheetIds from 1 in sheet order, so a rewritten workbook
changes the id of any sheet that is not part of a gapless series from 1.
Anything that recognises a sheet by its id (parts carried through as they
are, external tools) loses it. The model keeps the sheetId it read
(Sheet::file_id, PreservedSheet::sheet_id, not serialised to JSON) and the
writer writes it back; a new sheet, or a copy with a taken id, gets the first
free one.
2026-10-25 01:31:19 +02:00
Marco Arnulfo 0cf9476241 Shift+Arrow on a whole column or row: still follow an extension that reaches the sheet's edge itself (#2451) 2026-10-24 23:01:27 +02:00
Harrison Booth a2aa72c3cb Docs: drop the stale Options-dialog-missing note from the Backstage row 2026-10-24 15:54:20 -05:00
Marco Arnulfo a75d633d68 Shift+Arrow on a whole column or row keeps the view on the rows or columns it showed (fixes #2451) 2026-10-24 22:51:59 +02:00
Harrison Booth 3ef94601d5 Options: commit iteration fields only while iteration is on; accept the locale's decimal comma for Maximum Change
Fields still holding unconfirmed text when the checkbox turns iteration off are no longer committed,
and Maximum Change parses with the interface's decimal separator as well as a dot.
2026-10-24 15:47:17 -05:00
Harrison Booth 6bdde35133 Merge origin/main into gc-options-iteration-fields
# Conflicts:
#	ROADMAP.md
2026-10-24 15:12:26 -05:00
Marco Arnulfo b5bf14db98 Trace arrows stay on the traced cell when the selection moves (part of #2475) 2026-10-24 20:08:41 +02:00
NetterSilverandClaude Haiku 5.5 4c0cc09505 Fix parsing of ranges whose second end repeats the sheet name
Found while testing crates/formula/src/parser.rs (reference/sheet-prefix parsing) with crates/formula/src/printer.rs

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:48:21 +00:00
NetterSilverandClaude Haiku 5.5 b4487663aa Fix YEARFRAC basis 0 for February-end start and 31st end
Found while testing crates/functions/src/date.rs:443-457 (yearfrac_core, basis 0)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:10:13 +02:00
NetterSilverandClaude Haiku 5.5 92d2b9894f Fix IMSQRT leaving a stray real part for negative reals
Found while testing crates/functions/src/engineering.rs:713 (imsqrt, via Cx::powf at :562)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:24:48 +00:00
NetterSilverandClaude Haiku 5.5 86064077f1 Fix ISOWEEKNUM/WEEKNUM(…,21) for the first days of the calendar
Found while testing crates/functions/src/date.rs:123-129 (isoweek)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:04:35 +02:00
Arie NugrahaandClaude Code 276cd0fe78 web: parse ?chrome= so an embedding host can leave out the title bar and File tab
#1877 added Chrome to SheetApp for hosts that embed the sheet natively, but the web build never set it: an iframe host that owns the files still draws the title bar it duplicates, and Backstage's Open/Save As can replace the document behind the host's back. Parse the flag where the other URL flags are parsed; the UI already honours Chrome, so nothing else changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-24 13:00:37 +00:00
NetterSilverandClaude Haiku 5.5 6cb3220164 Scale MINVERSE rounding cutoff to the input matrix
Found while testing crates/functions/src/math.rs:923

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:09:26 +00:00
NetterSilverandClaude Haiku 5.5 14415f410d Fix PERCENTRANK returning #NUM! for very large significance
Found while testing crates/functions/src/stat.rs:257-260 (trunc_digits, used by percentrank_impl)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:16:03 +02:00
Harrison Booth a80c7ff570 i18n: Japanese labels for the iteration settings live in ja.rs
The merge from main moved the Japanese catalog into i18n/ja.rs, but the
feature's old table stayed in i18n.rs and was never used, which failed
clippy's dead-code lint. Drop the table and add the four new labels and
messages to ja.rs.
2026-10-23 16:43:24 -05:00
Harrison Booth 1062604fee i18n: Russian Maximum Change reads as a change limit
The Russian labels for Maximum Change said relative error, which is a
different quantity. They now say maximum change.
2026-10-23 16:32:50 -05:00
Harrison Booth ac890b839a Merge origin/main into gc-options-iteration-fields
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/tests/it/file_menu.rs
#	docs/function-parity.md
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-23 16:28:15 -05:00
vangel76 1da640abcc Merge upstream/main into fix/control-auth-777 2026-10-22 19:32:43 +02:00
aiongg 2ed3df2bd6 G4: two windows on one workbook without copying it; Arrange All
- The workbook's active sheet is an atomic set in place (Workbook::active_sheet /
  set_active_sheet), so a window swap or sheet switch never clones the
  Arc<Workbook> or marks it dirty. Selections already live in DocState and the
  window views. Save records the sheet of the most recently active window.
- A dialog taken out of its window while it runs goes back to that window when
  a command run from it switched windows (dialogs::show, ui.dialog.confirm).
- view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?}
  returns the visible windows, active first; the desktop moves and sizes the
  native windows over the main window's monitor. New Window and Arrange All
  buttons on the View ribbon; Arrange Windows dialog.
- Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP.
2026-10-22 16:50:10 +00:00
aiongg d4db58cde4 G3: feedback from a command that switched windows shows in the window it left active
after_engine, show_open_warnings and alert bring the per-window state in line
with the engine's active window first, so a notice, message or toast from
opening a workbook (also by a script on the session) lands in the opened
workbook's window instead of being parked with the previous one.
2026-10-22 16:29:24 +00:00
aiongg 5bd5d241a0 G3: a native window per workbook window
Each engine window is drawn in its own native window through craft-window's
immediate-viewport driver (desktop only; the web app keeps one window). The
root viewport shows one window and adopts another when its window closes.

- WindowUi: the cell draft, text-box draft, dialog, Backstage, message,
  toast, notice, grid state, name box, key tips, selected chart, Tab run and
  edit mode are per window. SheetApp's fields hold the active window's; the
  others are parked and swapped in when a window is drawn or activated, and
  after any command that switched windows.
- Session::show_window makes a window current for drawing without counting
  as an activation.
- The ribbon in each window acts on it; menu bar commands and scripts go to
  the window focused last. Leaving a window enters its draft as Enter would.
- A window's close button runs window.close: one of several windows on a
  workbook closes without asking, a workbook's last asks when unsaved.
  SheetApp::request_quit (macOS Quit item, Dock quit) asks about each unsaved
  workbook in its window; Cancel stops. Save Changes answers moved to
  SheetApp::answer_save_changes.
- The last window closed quits on Windows and Linux; macOS hides the root
  and keeps running until New or Open.
- Fonts are bound for every open workbook, since each may be showing.

craft-window is pinned to the aiongg/craft-libs window-list branch until it
merges into storytold/craft-libs.
2026-10-22 16:21:59 +00:00
aiongg 45d1d1acc0 G2: windows on workbooks in the engine; New Window shares the workbook
Session keeps a window list (stable ids, per-window selection, active sheet
and per-sheet selections swapped into the workbook without dirtying it)
in place of the index-based hidden list and the copying New Window.
Titles read Name:1 / Name:2 for a workbook with several windows.
File > Close closes one window of several; window.close, window.list and
window ids for window.activate, view.switchWindows, view.hideWindow and
view.unhideWindow. Control channel and MCP tools take window/doc ids;
scroll positions and cell drafts belong to their window.
2026-10-22 14:50:53 +00:00
aiongg 4bafd4e8ee Docs: Switch Windows and drafts kept in their workbook (UI parity, progress log) 2026-10-22 05:12:23 +00:00
aiongg a0d54b49d4 A refused cell draft stops the command; an untouched F2 draft changes nothing
- File and window commands stop when the workbook refuses the cell or
  text-box draft (validation Stop, bad formula, protected text box): the
  draft stays open in its cell with the reason shown, to fix or Esc. The
  per-frame backstop brings a refused draft's workbook back to the front.
- An F2 draft committed with its text unchanged writes nothing: no undo
  step, and the workbook stays clean through Save, Print or New.
- macOS Window menu: the active workbook's check is put back every poll,
  since choosing a check item flips it.
2026-10-22 05:12:23 +00:00
aiongg 1794c389e5 Keep the cell draft in its own workbook; Switch Windows lists open workbooks
A cell being typed when File > New ran (macOS menu, control channel) stayed
open over the new workbook, and Enter wrote it there; the first workbook
stayed clean and nothing in the UI reached it.

- Every file.* and window command (window.activate, view.newWindow,
  view.hideWindow, view.unhideWindow, view.switchWindows with a doc) first
  commits the cell and text-box drafts as Enter would, or drops a draft its
  workbook refuses, still reporting why. Finder, argv and dropped opens go
  through file.open and get the same.
- The cell editor records its workbook's uid; the grid draws a draft only
  over that workbook, and a commit always goes into it.
- view.switchWindows takes a `doc` uid to bring that workbook forward and
  lists index, doc, title, dirty, active and hidden. Menu-style calls with
  no doc open the View tab's new Switch Windows dropdown.
- macOS: the Window menu lists the open workbooks (unsaved bullet, active
  check), and Quit closes the window so unsaved workbooks are asked about.
2026-10-22 05:01:47 +00:00
vangel76andClaude Sonnet 5.5 f1cb4f00f4 Merge upstream/main into fix/control-auth-777
Keep the token check on the first line and the new bounded-line request parsing;
tests authenticate before sending their requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:03:55 +02:00
Harrison BoothandClaude Opus 5.5 5bd6cc3d04 Options: Maximum Iterations and Maximum Change in File > Options > Formulas
The Formulas section had the calculation-mode radios and Enable iterative
calculation but not the two limits that formulas.calculationOptions already
takes. They now sit under the checkbox, disabled while iteration is off, and
commit when the field loses focus. Like Excel, out-of-range entries
(Maximum Iterations outside 1-32767, a negative or non-numeric Maximum
Change) are refused with a message instead of being clamped by the command;
the Enter that committed the field no longer also dismisses that message.

Labels and messages are translated for every interface language. A kittest
covers the disabled state, valid and invalid entries, and undo refreshing
the field. Drops the stale "no Options dialog yet" from gaps.md and
function-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:19:03 -05:00
vangel76andClaude Sonnet 5.5 392fb29727 Merge upstream/main into fix/control-auth-777
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:08:11 +02:00
vangel76andClaude Sonnet 5.5 fef1f4ddb1 Security: the --control server requires an auth token and closes bad connections (#777)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:25:14 +02:00
249 changed files with 13240 additions and 2079 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: gridcraft
app-title: GridCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1 -1
View File
@@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- 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`.
+4
View File
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
+1 -1
View File
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- 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`.
Generated
+2217 -217
View File
File diff suppressed because it is too large Load Diff
+31 -7
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.11.1"
version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/gridcraft"
[workspace.dependencies]
@@ -24,6 +24,9 @@ gridcraft-engine = { path = "crates/engine" }
gridcraft-ui-egui = { path = "crates/ui-egui" }
gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -43,10 +46,10 @@ sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
@@ -55,7 +58,10 @@ sys-locale = "0.3"
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
epaint_default_fonts = "0.36"
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -79,3 +85,21 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+262 -2
View File
@@ -12,5 +12,265 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
Font License 1.1.
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://github.com/source-foundry/Hack
The following text is reproduced verbatim from Hack-Regular.txt:
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
The following text is reproduced verbatim from OFL.txt:
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
is distributed under the Ubuntu Font Licence, Version 1.0.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://design.ubuntu.com/font
The following text is reproduced verbatim from UFL.txt:
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+14 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
@@ -146,6 +147,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in |
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -155,6 +160,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
@@ -173,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -235,9 +242,14 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it |
| 2026-10-22 | minor | Progress log: a native window per workbook window |
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
@@ -256,6 +268,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
+17 -1
View File
@@ -195,7 +195,18 @@ fn pretty(v: &Value) -> String {
/// A number as a person would type it: `6`, `0.5`, `1e+300`.
fn number_text(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") }
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
} else if n.is_finite() && (n.abs() >= 1e15 || n.abs() < 1e-5) {
// Rust's `Display` never uses exponent form; `{:e}` omits the `+` on positive exponents.
let s = format!("{n:e}");
match s.split_once('e') {
Some((m, e)) if !e.starts_with('-') => format!("{m}e+{e}"),
_ => s,
}
} else {
format!("{n}")
}
}
/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text.
@@ -606,6 +617,11 @@ mod tests {
fn value_texts() {
assert_eq!(value_text(&json!(6.0)), "6");
assert_eq!(value_text(&json!(0.25)), "0.25");
assert_eq!(value_text(&json!(1e300)), "1e+300");
assert_eq!(value_text(&json!(-1.5e300)), "-1.5e+300");
assert_eq!(value_text(&json!(1e-20)), "1e-20");
assert_eq!(value_text(&json!(1e-300)), "1e-300");
assert_eq!(value_text(&json!(0.0001)), "0.0001");
assert_eq!(value_text(&json!(true)), "TRUE");
assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!");
assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx");
+3 -2
View File
@@ -11,8 +11,9 @@
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
//! `localStorage` (`prefs`).
//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
+19 -4
View File
@@ -8,6 +8,7 @@ use wasm_bindgen::JsValue;
const CANVAS_ID: &str = "gridcraft_canvas";
const LOADING_ID: &str = "gridcraft_loading";
/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -60,8 +61,21 @@ pub fn start() {
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// 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.
// `?chrome=none|notitlebar|nofiletab`: a host that embeds the sheet in its own window and owns the files
// can leave the title bar and/or the File tab undrawn (`Chrome`, #1877). An unknown value keeps
// everything, as the default does.
if let Some(chrome) = query_param("chrome") {
match chrome.as_str() {
"none" => app.chrome = gridcraft_ui_egui::Chrome { title_bar: false, file_tab: false },
"notitlebar" => app.chrome.title_bar = false,
"nofiletab" => app.chrome.file_tab = false,
_ => {}
}
}
// Follow the browser's language, except a CJK one. CJK coverage comes from
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
// cannot be assumed to render localized labels without tofu. Users can still
// choose a language explicitly.
let browser = gridcraft_ui_egui::i18n::Language::system();
if !matches!(
browser,
@@ -156,8 +170,9 @@ impl eframe::App for WebShell {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
/// Forwards a browser frame's raw input to the shared app before eframe handles it.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+11 -2
View File
@@ -13,7 +13,7 @@ workspace = true
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -24,10 +24,19 @@ chrono = { workspace = true }
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
gridcraft-mcp = { workspace = true }
# The control channel's token (OS random numbers); already in the tree.
getrandom = "0.4"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
+2 -1
View File
@@ -43,7 +43,8 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
app.open_path(p);
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
Event::Quit => app.request_quit(ctx),
}
}
}
+138 -8
View File
@@ -1,40 +1,85 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
//!
//! The first line of a connection must authenticate with the app's token (#777; see
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
//! JSON, is answered once and closed.
//!
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
/// How long a new connection may take to send its token.
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
/// else 256 random bits from the operating system, written to a file only the user can read.
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
if t.len() < control_auth::MIN_TOKEN_LEN {
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
}
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
return Ok(t);
}
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
let token = control_auth::hex(&bytes);
let path = control_auth::write_token_file(port, &token)?;
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
Ok(token)
}
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let rx = serve_listener(listener, ctx)?;
let token: Arc<str> = make_token(port, chosen_token)?.into();
let rx = serve_listener(listener, token, ctx)?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
}
})?;
Ok(rx)
}
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
let mut first = String::new();
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
let verdict = match read {
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
_ => Err(json!({"ok": false, "error": "authentication required"})),
};
let allowed = verdict.is_ok();
let reply = verdict.unwrap_or_else(|e| e);
let _ = writeln!(out, "{reply}");
let _ = out.set_read_timeout(None);
allowed
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
@@ -103,10 +148,13 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
if !authenticate(&mut reader, &mut out, token) {
return;
}
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
@@ -154,21 +202,29 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// Send `input` on a new connection and read reply lines until the server closes it.
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
/// Authenticate a new connection, send `input`, and read reply lines (after the
/// authentication reply) until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
c.write_all(input).unwrap();
// A read timeout ends the list too: the server kept the connection open.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
let mut replies: Vec<Json> = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
replies.remove(0);
replies
}
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, egui::Context::default()).unwrap())
(addr, serve_listener(listener, TOKEN.into(), egui::Context::default()).unwrap())
}
/// A browser can POST to the port: its request line closes the connection before the JSON
@@ -194,8 +250,11 @@ mod tests {
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
let mut r = BufReader::new(c);
let mut auth_reply = String::new();
r.read_line(&mut auth_reply).unwrap();
for id in [1, 2] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
@@ -221,4 +280,75 @@ mod tests {
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// A server on a loopback port with a stand-in UI that echoes each method.
fn spawn() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
}
});
addr
}
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(send.as_bytes()).unwrap();
// Replies up to the server closing the connection.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
}
#[test]
fn the_token_opens_the_connection() {
let addr = spawn();
let auth = control_auth::auth_line(1, TOKEN);
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
line.clear();
r.read_line(&mut line).unwrap();
let v: Json = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
}
#[test]
fn anything_but_the_token_first_closes_the_connection() {
let addr = spawn();
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
// The second request is never served: one refusal, then end of stream.
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
assert_eq!(got.len(), 1, "{first}: {got:?}");
assert_eq!(got[0]["ok"], json!(false), "{first}");
}
}
#[test]
fn an_unparsable_line_after_auth_closes_the_connection() {
let addr = spawn();
let auth = control_auth::auth_line(1, TOKEN);
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
assert_eq!(got.len(), 2, "{got:?}");
assert_eq!(got[1]["ok"], json!(false));
}
#[test]
fn a_chosen_token_must_be_long_enough() {
assert!(make_token(1, Some("short".into())).is_err());
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
}
}
+45 -12
View File
@@ -1,9 +1,10 @@
//! GridCraft desktop app.
//!
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
//!
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
#![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)]
@@ -22,31 +23,34 @@ use std::cell::RefCell;
use std::rc::Rc;
use gridcraft_engine::Session;
use gridcraft_ui_egui::doc_windows::DocWindows;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events.
struct App(SheetApp, DocWindows, #[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) {
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(ctx));
if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.2 = Some(native_menu::NativeMenu::install(ctx));
}
if let Some(m) = &self.1 {
m.poll(&mut self.0, ctx);
if let Some(m) = &mut self.2 {
m.poll(&mut self.0, ctx, self.1.focused_viewport());
}
apple_events::poll(&self.2, &mut self.0, ctx);
apple_events::poll(&self.3, &mut self.0, ctx);
}
self.0.logic(ctx);
self.0.poll_printing(ctx);
self.1.logic(&mut self.0, ctx);
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forwards raw input to the shared app, then opens every non-empty dropped file path as a workbook.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
// Files dropped on the window open as workbooks.
for f in std::mem::take(&mut raw.dropped_files) {
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
@@ -55,7 +59,7 @@ impl eframe::App for App {
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.1.show(&mut self.0, ui);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
@@ -251,11 +255,20 @@ fn main() -> eframe::Result<()> {
}
}));
let args: Vec<String> = std::env::args().skip(1).collect();
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("GridCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
let mut sample: Option<String> = None;
let mut files: Vec<String> = Vec::new();
let mut i = 0;
@@ -265,6 +278,10 @@ fn main() -> eframe::Result<()> {
i += 1;
control_port = args.get(i).and_then(|p| p.parse().ok());
}
"--control-token" => {
i += 1;
control_token = args.get(i).cloned();
}
"--sample" => {
i += 1;
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
@@ -299,6 +316,7 @@ fn main() -> eframe::Result<()> {
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.start_update_check();
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
@@ -320,7 +338,20 @@ fn main() -> eframe::Result<()> {
if let Some(i) = icon() {
viewport = viewport.with_icon(i);
}
let window_builder = viewport.clone();
let options = eframe::NativeOptions { viewport, ..Default::default() };
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
#[cfg(target_os = "windows")]
@@ -352,13 +383,15 @@ fn main() -> eframe::Result<()> {
};
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()) {
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => log::error!("control channel on port {port}: {e}"),
}
}
let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window());
Ok(Box::new(App(
app,
windows,
#[cfg(target_os = "macos")]
None,
#[cfg(target_os = "macos")]
+82 -8
View File
@@ -4,7 +4,7 @@
use std::str::FromStr;
use std::sync::mpsc::{Receiver, channel};
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::{OpenWindow, SheetApp};
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::{Value, json};
@@ -168,10 +168,20 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Calculate Now", "formulas.calculateNow", None),
],
),
("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]),
// The open workbooks follow the separator (`NativeMenu::sync_windows`).
(
"Window",
vec![
("New Window", "view.newWindow", None),
("Next Sheet", "sheet.next", None),
("Previous Sheet", "sheet.previous", None),
("-", "", None),
],
),
(
"Help",
vec![
("Check for Updates…", "app.checkForUpdates", None),
("Agent Control (MCP)…", "dialog:agents", None),
("Contributors…", "dialog:about:Contributors", None),
("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
@@ -185,8 +195,19 @@ pub struct NativeMenu {
_menu: Menu,
events: Receiver<MenuEvent>,
theme_items: Vec<(&'static str, CheckMenuItem)>,
window_menu: Option<Submenu>,
/// The Window menu's window items and what they last showed.
window_items: Vec<CheckMenuItem>,
windows: Vec<OpenWindow>,
}
/// Menu id of the Window menu item that brings workbook `doc` forward.
const WINDOW_ITEM: &str = "window:id:";
/// The Quit item: asks about each unsaved workbook in its window first
/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip.
const QUIT: &str = "app.quit";
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
let mode = match id {
"theme:system" => "system",
@@ -212,7 +233,7 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()),
]);
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
@@ -247,7 +268,7 @@ impl NativeMenu {
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
// the submenu up in the installed main menu.
if let Some(window) = window_menu {
if let Some(window) = &window_menu {
window.set_as_windows_menu_for_nsapp();
}
let (tx, rx) = channel();
@@ -256,12 +277,54 @@ impl NativeMenu {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeMenu { _menu: menu, events: rx, theme_items }
NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() }
}
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
/// Keeps the Window menu's list of open windows (title, unsaved bullet, active check) in
/// step with the session.
fn sync_windows(&mut self, app: &SheetApp) {
let windows = app.open_windows();
if windows != self.windows
&& let Some(menu) = &self.window_menu
{
let same_windows = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.window == b.window);
if same_windows {
for (item, w) in self.window_items.iter().zip(&windows) {
item.set_text(w.label());
}
} else {
for item in self.window_items.drain(..) {
let _ = menu.remove(&item);
}
for w in &windows {
let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.window), w.label(), true, w.active, None);
let _ = menu.append(&item);
self.window_items.push(item);
}
}
self.windows = windows;
}
// AppKit flips a check item when it is chosen, so the active check is put back every poll.
for (item, w) in self.window_items.iter().zip(&self.windows) {
if item.is_checked() != w.active {
item.set_checked(w.active);
}
}
}
/// Runs the chosen menu items. Commands go to the active window, the most recently focused;
/// `viewport` is its egui viewport, for the items that act on its input.
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) {
while let Ok(ev) = self.events.try_recv() {
let id = ev.id.as_ref().to_string();
if id == QUIT {
app.request_quit(ctx);
continue;
}
if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::<u64>().ok()) {
app.run_or_alert("view.switchWindows", json!({"window": window}));
continue;
}
// 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() {
@@ -271,11 +334,11 @@ impl NativeMenu {
_ => None,
};
if let Some(command) = clipboard {
ctx.send_viewport_cmd(command);
ctx.send_viewport_cmd_to(viewport, command);
continue;
}
if id == "edit.undo" || id == "edit.redo" {
ctx.input_mut(|i| {
ctx.input_mut_for(viewport, |i| {
i.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
@@ -321,6 +384,7 @@ impl NativeMenu {
item.set_checked(checked);
}
}
self.sync_windows(app);
}
}
@@ -337,6 +401,16 @@ mod tests {
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
}
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
let tree = tree();
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
assert_eq!(item.1, "app.checkForUpdates");
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
assert!(app.run(item.1, serde_json::json!({})).is_ok());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
+5 -1
View File
@@ -1,11 +1,15 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
+67 -53
View File
@@ -927,6 +927,70 @@ impl Host for PassHost<'_> {
}
}
/// Pops one finished component off Tarjan's stack, labelling its nodes with `comp_index`. The
/// component is everything up to and including `root`.
fn pop_component(stack: &mut Vec<usize>, on_stack: &mut [bool], comp: &mut [usize], comp_index: usize, root: usize) {
while let Some(w) = stack.pop() {
on_stack[w] = false;
comp[w] = comp_index;
if w == root {
break;
}
}
}
/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each
/// node's component index, and the number of nodes in each component.
fn components(edges: &[Vec<usize>]) -> (Vec<usize>, Vec<usize>) {
let n = edges.len();
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
let (mut next, mut comps) = (0usize, 0usize);
for root in 0..n {
if num[root] != usize::MAX {
continue;
}
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
num[root] = next;
low[root] = next;
next += 1;
stack.push(root);
on_stack[root] = true;
while let Some(top) = call.last_mut() {
let v = top.0;
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
top.1 += 1;
if num[w] == usize::MAX {
num[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
call.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(num[w]);
}
continue;
}
call.pop();
if let Some(&(u, _)) = call.last() {
low[u] = low[u].min(low[v]);
}
if low[v] == num[v] {
pop_component(&mut stack, &mut on_stack, &mut comp, comps, v);
comps += 1;
}
}
}
let mut size = vec![0usize; comps];
for &c in &comp {
if let Some(s) = size.get_mut(c) {
*s += 1;
}
}
(comp, size)
}
impl Calc {
pub fn new() -> Self {
Self::default()
@@ -967,6 +1031,7 @@ impl Calc {
if circ.is_empty() {
return Vec::new();
}
let n = circ.len();
let index: HashMap<Key, usize> = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect();
// Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way.
let mut deps = Vec::new();
@@ -981,59 +1046,8 @@ impl Calc {
e
})
.collect();
// Tarjan's strongly connected components, iterative.
let n = circ.len();
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
let (mut next, mut comps) = (0usize, 0usize);
for root in 0..n {
if num[root] != usize::MAX {
continue;
}
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
num[root] = next;
low[root] = next;
next += 1;
stack.push(root);
on_stack[root] = true;
while let Some(top) = call.last_mut() {
let v = top.0;
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
top.1 += 1;
if num[w] == usize::MAX {
num[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
call.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(num[w]);
}
continue;
}
call.pop();
if let Some(&(u, _)) = call.last() {
low[u] = low[u].min(low[v]);
}
if low[v] == num[v] {
while let Some(w) = stack.pop() {
on_stack[w] = false;
comp[w] = comps;
if w == v {
break;
}
}
comps += 1;
}
}
}
let mut size = vec![0usize; comps];
for &c in &comp {
if let Some(s) = size.get_mut(c) {
*s += 1;
}
}
// Loop members: nodes in a component of more than one, or with an edge to themselves.
let (comp, size) = components(&edges);
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
let mut out: Vec<Key> =
+11
View File
@@ -174,6 +174,9 @@ pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if frac_ms >= 86_400_000 {
frac_ms -= 86_400_000;
day += 1;
if day > max_serial(sys) {
return None;
}
}
let (y, m, d, weekday) = match sys {
DateSystem::D1900 => {
@@ -221,6 +224,14 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[test]
fn serial_rounding_into_next_day_past_max_is_none() {
for sys in [DateSystem::D1900, DateSystem::D1904] {
let serial = max_serial(sys) as f64 + 0.9999999995;
assert_eq!(datetime_from_serial(sys, serial), None);
}
}
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
+1 -1
View File
@@ -1182,7 +1182,7 @@ mod tests {
fn v(s: &Session, a: &str) -> Value {
let d = s.doc().unwrap();
let (sh, body) = crate::cmd::split_sheet(a);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
}
fn n(s: &Session, a: &str) -> f64 {
+38 -34
View File
@@ -167,7 +167,7 @@ fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
{
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
@@ -219,7 +219,7 @@ fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
{
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
@@ -511,7 +511,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.filter")?;
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start };
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
if let Some(name) = in_table {
@@ -563,7 +563,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
}
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let af = match &sh.autofilter {
@@ -768,7 +768,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
}
fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
@@ -781,7 +781,7 @@ fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
}
fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
@@ -1285,7 +1285,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let input = str_param(p, "input").unwrap_or("");
match check_validation(&d.wb, d.wb.active_sheet, at, input) {
match check_validation(&d.wb, d.wb.active_sheet(), at, input) {
None => Ok(json!({"ok": true})),
Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})),
}
@@ -1293,7 +1293,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let mut bad_cells = Vec::new();
for dv in &sh.validations {
@@ -1332,7 +1332,7 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
@@ -1454,6 +1454,34 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
@@ -1469,31 +1497,7 @@ fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
if was_before != on {
continue;
}
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
.into_iter()
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
.collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
+2 -2
View File
@@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
let dx = (minx - sh.col_left(cell.col)) as f32;
@@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
}
fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
+16 -21
View File
@@ -237,7 +237,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other(m));
}
let active_sheet = s.doc()?.wb.active_sheet;
let active_sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
@@ -438,23 +438,18 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
impl crate::DocState {
/// Switches the active sheet, remembering each sheet's selection.
pub fn wb_switch_sheet(&mut self, i: usize) {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet() {
return;
}
let cur = self.wb.active_sheet;
let cur = self.wb.active_sheet();
if self.sheet_selections.len() < self.wb.sheets.len() {
self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default());
}
if let Some(slot) = self.sheet_selections.get_mut(cur) {
*slot = self.selection.clone();
}
// The active sheet is view state: change it without making the workbook dirty.
let dirty = self.is_dirty();
let wb = std::sync::Arc::make_mut(&mut self.wb);
wb.active_sheet = i;
if !dirty {
self.saved = self.wb.clone();
}
// View state: set in place, so the workbook is neither copied nor made dirty.
self.wb.set_active_sheet(i);
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
}
}
@@ -587,7 +582,7 @@ const GO_TO_NAME_DEPTH: usize = 8;
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent}));
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
let d = s.doc()?;
@@ -595,7 +590,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
@@ -1701,7 +1696,7 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
let workbook = str_param(p, "within") == Some("workbook");
s.last_find = Some(p.clone());
let d = s.doc()?;
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] };
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
let mut hits: Vec<(usize, CellRef, String)> = Vec::new();
for si in sheets {
let Some(sh) = d.wb.sheet(si) else { continue };
@@ -1731,7 +1726,7 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
return Ok(json!({"count": list.len(), "results": list}));
}
// Next hit after the active cell.
let cur = (d.wb.active_sheet, d.selection.active);
let cur = (d.wb.active_sheet(), d.selection.active);
let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) };
let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned();
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
@@ -1753,7 +1748,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let workbook = str_param(p, "within") == Some("workbook");
let active = s.doc()?.selection.active;
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] };
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
let mut n = 0;
for si in sheets {
let cells: Vec<(CellRef, String)> =
@@ -1825,7 +1820,7 @@ fn replace_ci(text: &str, what: &str, with: &str) -> String {
fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) {
let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) };
let dest_sheet = s.doc()?.wb.active_sheet;
let dest_sheet = s.doc()?.wb.active_sheet();
let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles;
edit(s, |cx| {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
@@ -1905,7 +1900,7 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
}
let sticky = bool_param(p, "sticky").unwrap_or(false);
let d = s.doc()?;
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet, d.selection.current(), sticky));
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky));
ok()
}
@@ -1965,11 +1960,11 @@ mod tests {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
assert_eq!(out["selection"], "C3", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// A name of several areas selects all of them.
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
}
/// A name matches in any case and any script, as a formula matches it.
@@ -1996,7 +1991,7 @@ mod tests {
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
assert_eq!(out["selection"], "A1,B2", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
@@ -2035,7 +2030,7 @@ mod tests {
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
for i in 0..GO_TO_RECENT + 2 {
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
}
+81 -41
View File
@@ -141,11 +141,27 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views),
cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split),
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window),
cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()),
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window),
cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows),
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window),
cmd!(
noundo "view.arrangeAll",
"Arrange All",
["View", "Window"],
None,
"{arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?: bool} → {arrange, windows}: lays out the visible windows (tiled by default; `activeWorkbook` only the active workbook's), the active window first",
has_doc,
arrange_all
),
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window),
cmd!(
noundo "view.switchWindows",
"Switch Windows",
["View", "Window"],
None,
"{window?, doc?} → open windows [{window, doc, index, title, dirty, active, hidden}]; with `window` (an id from the list), or `doc` (a workbook id: its most recent window), shows and activates that window first",
always,
switch_windows
),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler),
cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts),
// Table tools
@@ -294,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
@@ -460,7 +476,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
@@ -482,7 +498,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
@@ -564,7 +580,7 @@ fn shape_outline(s: &mut Session, p: &Json) -> Result<Json> {
}
fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let shapes = &mut cx.sheet_mut(si)?.shapes;
@@ -582,7 +598,7 @@ fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
@@ -648,7 +664,7 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
@@ -761,7 +777,7 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let mut views = stash_map(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
@@ -808,47 +824,71 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
}
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?.clone();
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust);
copy.selection = d.selection.clone();
copy.scripts_trusted = d.scripts_trusted;
let i = s.add_document(copy);
Ok(json!({"index": i}))
let id = s.new_window()?;
let doc = s.window(id).map(|w| w.doc_uid);
Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)}))
}
fn hide_window(s: &mut Session, _: &Json) -> Result<Json> {
s.hidden_windows.push(s.active_index());
let n = s.documents().len();
if n > 1 {
let next = (0..n).find(|i| !s.hidden_windows.contains(i)).unwrap_or(0);
s.set_active(next);
/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most
/// recent window), else the active window.
pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result<u64> {
let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?;
s.target_window(t).map_err(|e| bad(cmd, e.to_string()))
}
/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout,
/// and the interface moves its native windows ([`Session::window_list`] order, active first).
fn arrange_all(s: &mut Session, p: &Json) -> Result<Json> {
let arrange = match str_param(p, "arrange").unwrap_or("tiled") {
a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a,
other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))),
};
let active = s.active_window();
let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid);
let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false);
let mut windows: Vec<u64> = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect();
if let Some(at) = windows.iter().position(|w| Some(*w) == active)
&& let Some(first) = windows.get_mut(..=at)
{
first.rotate_right(1);
}
Ok(json!({"arrange": arrange, "windows": windows}))
}
fn hide_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = window_param(s, "view.hideWindow", p)?;
s.hide_window(id);
ok()
}
fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").map(|v| v as usize).or_else(|| s.hidden_windows.last().copied());
if let Some(i) = i {
s.hidden_windows.retain(|x| *x != i);
s.set_active(i);
let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
Some(window_param(s, "view.unhideWindow", p)?)
} else if let Some(i) = u32_param(p, "index") {
// A workbook's position in the document list.
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?;
s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid))
} else {
s.last_hidden_window()
};
if let Some(id) = id {
s.unhide_window(id);
}
ok()
}
fn switch_windows(s: &mut Session, _: &Json) -> Result<Json> {
Ok(Json::Array(
s.documents()
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "title": d.display_title(), "active": i == s.active_index(), "hidden": s.hidden_windows.contains(&i)}))
.collect(),
))
fn switch_windows(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
let id = window_param(s, "view.switchWindows", p)?;
s.unhide_window(id);
}
Ok(s.window_list())
}
fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let ti = match str_param(p, "table") {
Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)),
@@ -957,7 +997,7 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r };
let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r);
let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r);
from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::<String>()}))
}
@@ -1036,7 +1076,7 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
}
// Scripts are scoped to their workbook: a step that switches the active window would
// point every later step at another open document, so those are refused too.
if id.starts_with("window.") {
if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
@@ -1207,7 +1247,7 @@ fn theme_fonts(s: &mut Session, p: &Json) -> Result<Json> {
fn background(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "clear").unwrap_or(false) {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
return edit(s, |cx| {
cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background");
Ok(Json::Null)
@@ -1300,7 +1340,7 @@ fn watch_window(s: &mut Session, p: &Json) -> Result<Json> {
.iter()
.map(|w| {
let (sheet, body) = split_sheet(w);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet());
let c = CellRef::parse(body).unwrap_or_default();
let sh = d.wb.sheet(si);
json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))})
+45 -9
View File
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
@@ -25,7 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
always,
options
),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window),
cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window),
cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())),
]
}
@@ -160,7 +162,16 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
/// File › Close closes the active window; the workbook closes with its last window.
fn close(s: &mut Session, p: &Json) -> Result<Json> {
if !bool_param(p, "document").unwrap_or(false)
&& let Some(id) = s.active_window()
&& let Some(doc) = s.window(id).map(|w| w.doc_uid)
&& s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1
{
s.close_window(id);
return Ok(json!({"closed": true, "document": false}));
}
let force = bool_param(p, "force").unwrap_or(false);
let d = s.doc()?;
if d.is_dirty() && !force {
@@ -175,9 +186,9 @@ fn close(s: &mut Session, p: &Json) -> Result<Json> {
fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let d = s.doc()?;
let mut wb = (*d.wb).clone();
let wb = (*d.wb).clone();
if p.get("sheet").is_some() {
wb.active_sheet = target_sheet(s, p)?;
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
@@ -221,10 +232,35 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
}
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {
return Err(bad("window.activate", "no such window"));
}
s.set_active(i);
let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) {
Some(i) => {
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?;
s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))?
}
None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => {
return Err(bad("window.activate", "missing `window`"));
}
None => super::extra::window_param(s, "window.activate", p)?,
};
s.unhide_window(id);
ok()
}
fn close_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = super::extra::window_param(s, "window.close", p)?;
let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default();
if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 {
s.close_window(id);
return Ok(json!({"closed": true, "document": false}));
}
let force = bool_param(p, "force").unwrap_or(false);
let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::EngineError::NoDocument)?;
if d.is_dirty() && !force {
let title = d.display_title();
s.activate_window(id);
s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": title})));
return Ok(json!({"closed": false, "dirty": true}));
}
s.close_window(id);
Ok(json!({"closed": true, "document": true}))
}
+5 -5
View File
@@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
let r = if r.is_full_cols() || r.is_full_rows() {
@@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)>
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
(d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
}
};
if r.width() < 2 {
@@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let stats_col = width + 1;
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
let at = s.doc()?.wb.active_sheet;
let at = s.doc()?.wb.active_sheet();
let index = edit(s, |cx| {
let at = at.min(cx.wb.sheets.len());
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
@@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc += 1;
}
}
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(at)
@@ -502,7 +502,7 @@ mod tests {
assert_eq!(r["periods"], 4);
let wb = &s.doc().unwrap().wb;
// Inserted before the data sheet and activated.
assert_eq!(wb.active_sheet, 0);
assert_eq!(wb.active_sheet(), 0);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
+1 -1
View File
@@ -1027,7 +1027,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet {
if sheet != d.wb.active_sheet() {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
+99 -42
View File
@@ -96,42 +96,73 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
/// Whether a cell on the active sheet is empty.
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
sh.value(CellRef::new(row, col)).is_empty()
}
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
format!("={func}({})", RangeRef::new(from, to).a1())
}
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return Ok(None) };
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
}
/// One row: its total goes in its last cell when that is empty, else just after it.
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let row = sel.start.row;
let last_col = used.end.col.min(sel.end.col);
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
}
/// A block of rows: each column's total below it, and each row's total in an empty last column.
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let mut writes: Vec<(CellRef, String)> = Vec::new();
let last_col = used.end.col.min(sel.end.col);
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
Ok(writes)
}
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
let numeric = |c: CellRef| sh.value(c).is_number();
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
@@ -153,7 +184,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
let text = match range {
range
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet();
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let text = match guessed_range(sh, at) {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
@@ -441,7 +498,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
.iter()
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
let sheet = n.scope.unwrap_or(d.wb.active_sheet());
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
@@ -523,7 +580,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet;
let sheet = wb.active_sheet();
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
@@ -557,7 +614,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
@@ -577,7 +634,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
}
@@ -586,7 +643,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps);
deps.sort();
deps.dedup();
let v: Vec<Json> = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect();
@@ -599,7 +656,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Jso
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
@@ -670,7 +727,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
let on = bool_param(p, "on").unwrap_or(!cur);
edit(s, |cx| {
@@ -740,7 +797,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
None => {
@@ -779,7 +836,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
+24 -13
View File
@@ -371,7 +371,7 @@ fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(n) = str_param(p, "table") {
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
}
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let ti = sh
.tables
@@ -835,7 +835,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
@@ -984,7 +984,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
_ => SparklineKind::Line,
};
let markers = bool_param(p, "markers").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.sparklines.retain(|x| x.cell != loc);
@@ -1009,7 +1009,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
if placement == "cell" {
let mime = validate_cell_picture(&data)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet;
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"))?,
@@ -1053,7 +1053,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.images.push(img);
Ok(json!({"image": id}))
@@ -1123,7 +1123,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
@@ -1141,7 +1141,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet;
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 {
@@ -1164,7 +1164,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
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();
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
@@ -1180,7 +1180,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
@@ -1221,7 +1221,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
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;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
@@ -1317,9 +1317,20 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
let named = str_param(p, "author").map(str::to_string);
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
let comments = &mut cx.sheet_mut(sheet)?.comments;
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
c.text = text.clone();
if let Some(author) = named.clone() {
c.author = author;
}
return Ok(json!({"cell": at.a1()}));
}
comments.insert(
at,
Comment {
author: author.clone(),
@@ -1719,7 +1730,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
+1 -1
View File
@@ -64,7 +64,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet,
"activeSheet": wb.active_sheet(),
"sheets": sheets,
"selection": d.selection.a1(),
"activeCell": d.selection.active.a1(),
+3 -3
View File
@@ -296,7 +296,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
{
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
}
Ok(d.wb.active_sheet)
Ok(d.wb.active_sheet())
}
pub(crate) use gridcraft_core::split_sheet;
@@ -305,7 +305,7 @@ pub(crate) use gridcraft_core::split_sheet;
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
cell_param_on(s, p, key, s.doc()?.wb.active_sheet())
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
@@ -593,7 +593,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
impl Ctx<'_> {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
self.wb.active_sheet()
}
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
+13 -13
View File
@@ -224,7 +224,7 @@ fn other(msg: impl Into<String>) -> EngineError {
/// (sheet, index) of the pivot a call targets.
fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let active = d.wb.active_sheet;
let active = d.wb.active_sheet();
if let Some(v) = p.get("pivot").filter(|v| !v.is_null()) {
let mut order: Vec<usize> = vec![active];
order.extend((0..d.wb.sheets.len()).filter(|i| *i != active));
@@ -634,7 +634,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
return Err(other("The workbook is protected and cannot be changed."));
}
let source = source_param(s, p)?;
let active = s.doc()?.wb.active_sheet;
let active = s.doc()?.wb.active_sheet();
// Validate the source now (header names, size).
let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?;
let name = match str_param(p, "name") {
@@ -713,7 +713,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
sh.pivots.push(pt);
let pi = sh.pivots.len() - 1;
let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?;
cx.wb.active_sheet = si;
cx.wb.set_active_sheet(si);
*cx.sel = crate::Selection::at(anchor);
cx.structural = true;
let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -738,7 +738,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let at = cx.wb.active_sheet().min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
@@ -756,7 +756,7 @@ fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet;
let active = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
@@ -825,7 +825,7 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
}
}
let name = sh.name.clone();
cx.wb.active_sheet = si;
cx.wb.set_active_sheet(si);
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
@@ -1534,7 +1534,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
let sh = gridcraft_core::unquote_sheet(sh);
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
None => (Some(d.wb.active_sheet()), t),
};
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
@@ -1668,7 +1668,7 @@ fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
let source = source_param(s, p)?;
let active = s.doc()?.wb.active_sheet;
let active = s.doc()?.wb.active_sheet();
let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?;
let list = suggestions(&s.doc()?.wb, &src);
let Some(apply) = p.get("apply").and_then(Json::as_u64) else {
@@ -1751,7 +1751,7 @@ mod tests {
assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"]));
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 2);
assert_eq!(d.wb.active_sheet, 0, "new sheet goes before the current one");
assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one");
assert_eq!(d.wb.active().unwrap().name, "Sheet2");
assert!(text(&s, "A3").starts_with("PivotTable1"));
assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13");
@@ -2132,7 +2132,7 @@ mod tests {
}
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
let d = s.doc().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s));
data.series.into_iter().map(|x| x.values).collect()
}
@@ -2530,7 +2530,7 @@ mod tests {
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
@@ -2670,7 +2670,7 @@ mod tests {
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
}
let d = s.doc().unwrap();
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let pt = &d.wb.active().unwrap().pivots[0];
let r = pt.last_range.unwrap();
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
@@ -2688,6 +2688,6 @@ mod tests {
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
let d = s.doc().unwrap();
let pt = &d.wb.active().unwrap().pivots[0];
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet).unwrap_err().contains("out of date"));
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
}
}
+3 -3
View File
@@ -388,13 +388,13 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
let sheets: Vec<usize> = if opts.all_sheets {
(0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect()
} else {
vec![wb.active_sheet]
vec![wb.active_sheet()]
};
for si in sheets {
if pages >= MAX_PAGES {
break;
}
let ovr = if si == wb.active_sheet { opts.range } else { None };
let ovr = if si == wb.active_sheet() { opts.range } else { None };
if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) {
continue;
}
@@ -405,7 +405,7 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
}
}
if out.is_empty() && opts.all_sheets {
out.extend(layout_sheet(wb, wb.active_sheet, opts, None));
out.extend(layout_sheet(wb, wb.active_sheet(), opts, None));
}
out
}
+3 -3
View File
@@ -90,7 +90,7 @@ fn has_comments(s: &Session) -> std::result::Result<(), String> {
fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
let list = comment_cells(s);
let d = s.doc()?;
let here = (d.wb.active_sheet, d.selection.active);
let here = (d.wb.active_sheet(), d.selection.active);
let target = if forward {
list.iter().find(|c| **c > here).or_else(|| list.first())
} else {
@@ -374,7 +374,7 @@ mod tests {
fn at(s: &Session) -> (usize, String) {
let d = s.doc().unwrap();
(d.wb.active_sheet, d.selection.active.a1())
(d.wb.active_sheet(), d.selection.active.a1())
}
#[test]
@@ -443,7 +443,7 @@ mod tests {
);
let prot: SheetProtection = serde_json::from_str(&old).unwrap();
assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
let i = s.doc().unwrap().wb.active_sheet;
let i = s.doc().unwrap().wb.active_sheet();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
+15 -13
View File
@@ -206,11 +206,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
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) {
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
Some(c) => {
sp.cell = c;
true
}
}
None => false,
});
if let Some(pa) = sh.print.print_area {
sh.print.print_area = map_range(pa);
}
@@ -705,7 +707,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let before = p.get("before").and_then(Json::as_u64).map(|v| v as usize);
let idx = edit_doc(d, |cx| {
// Excel inserts before the active sheet; the "+" button appends after it.
let at = before.unwrap_or(cx.wb.active_sheet + 1).min(cx.wb.sheets.len());
let at = before.unwrap_or(cx.wb.active_sheet() + 1).min(cx.wb.sheets.len());
cx.wb.sheets.insert(at, Arc::new(Sheet::new(name.clone())));
// Names scoped to later sheets shift.
for n in cx.wb.names.iter_mut() {
@@ -715,7 +717,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc += 1;
}
}
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
*cx.sel = crate::Selection::default();
cx.structural = true;
Ok(at)
@@ -803,8 +805,8 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
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));
if cx.wb.active_sheet() >= cx.wb.sheets.len() || cx.wb.active_sheet() > i {
cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)));
}
*cx.sel = crate::Selection::default();
cx.structural = true;
@@ -918,12 +920,12 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
cx.wb.names.extend(copies);
cx.wb.sheets.insert(at, Arc::new(sh));
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
} else {
let sh = cx.wb.sheets.remove(i);
let at = to.min(n - 1);
cx.wb.sheets.insert(at, sh);
cx.wb.active_sheet = at;
cx.wb.set_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() {
@@ -960,8 +962,8 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
edit(s, |cx| {
cx.sheet_mut(i)?.visibility = Visibility::Hidden;
if cx.wb.active_sheet == i {
cx.wb.active_sheet = cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
if cx.wb.active_sheet() == i {
cx.wb.set_active_sheet(cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0));
}
Ok(Json::Null)
})
@@ -982,7 +984,7 @@ fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let i = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(i)?.visibility = Visibility::Visible;
cx.wb.active_sheet = i;
cx.wb.set_active_sheet(i);
Ok(Json::Null)
})
}
@@ -996,7 +998,7 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
let d = s.doc()?;
let mut i = d.wb.active_sheet;
let mut i = d.wb.active_sheet();
while let Some(next) = i.checked_add_signed(dir) {
let Some(sheet) = d.wb.sheet(next) else { break };
if sheet.visibility == Visibility::Visible {
+3 -3
View File
@@ -165,7 +165,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string();
let multi = bool_param(p, "multi").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let (table, column, current) = {
let d = s.doc()?;
let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?;
@@ -204,7 +204,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
sl.selected = None;
@@ -217,7 +217,7 @@ fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
fn slicer_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
// Absolute column of the slicer, so its filter criterion can be removed too.
let abs_col = s.doc().ok().and_then(|d| {
let sh = d.wb.active()?;
+6 -6
View File
@@ -301,7 +301,7 @@ fn parse_model(s: &Session, p: &Json) -> Result<Model> {
let d = s.doc()?;
let wb = &d.wb;
let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or("");
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?;
let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
if sh.cell(objective).is_none_or(|c| c.formula.is_none()) {
@@ -1913,8 +1913,8 @@ fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<St
*sc += count;
}
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
if cx.wb.active_sheet() >= at {
cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count));
}
cx.structural = true;
names
@@ -1939,7 +1939,7 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
let params = match p.get("model") {
Some(m @ Json::Object(_)) => m.clone(),
Some(Json::Null) | None => {
let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({}));
let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({}));
if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) {
for (k, v) in over {
base.insert(k.clone(), v.clone());
@@ -1988,7 +1988,7 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet;
let active = d.wb.active_sheet();
let mut params = p.clone();
if bool_param(p, "saved").unwrap_or(false) {
let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?;
@@ -2145,7 +2145,7 @@ mod tests {
/// The first saved-model name scoped to the active sheet.
fn saved(s: &Session) -> String {
let d = s.doc().unwrap();
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default()
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default()
}
fn set(s: &mut Session, cells: &[(&str, &str)]) {
for (c, v) in cells {
+2 -2
View File
@@ -149,7 +149,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
}
let at = match cell_param_on(s, p, "cell", si)? {
Some(c) => c,
None if si == d.wb.active_sheet => d.selection.active,
None if si == d.wb.active_sheet() => d.selection.active,
// The selection belongs to the active sheet.
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
};
@@ -225,7 +225,7 @@ fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let si = wb.active_sheet;
let si = wb.active_sheet();
if let Some(sh) = wb.sheet_mut(si) {
sh.zoom = z;
}
+7 -7
View File
@@ -101,7 +101,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
let d = s.doc()?;
let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
let (sh, body) = split_sheet(t);
let si = sheet_named(&d.wb, sh, d.wb.active_sheet, cmd)?;
let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?;
let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?;
Ok((si, c))
}
@@ -110,7 +110,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
/// are trimmed to the used range.
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
Ok((si, trim_to_used(wb, si, r)))
}
@@ -152,7 +152,7 @@ fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Ve
return Err(bad(cmd, format!("too many cells (at most {cap})")));
}
}
Ok((sheet.unwrap_or(wb.active_sheet), out))
Ok((sheet.unwrap_or(wb.active_sheet()), out))
}
/// What a JSON value typed into a cell is, as a constant.
@@ -525,7 +525,7 @@ fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioSummary";
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
if scenarios.is_empty() {
@@ -799,7 +799,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
Some(t) => parse_range(&d.wb, t, CMD)?,
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
}
};
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
@@ -1024,7 +1024,7 @@ fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
let (si, r) = parse_range(&d.wb, t, CMD)?;
(si, r.start)
}
None => (d.wb.active_sheet, d.selection.active),
None => (d.wb.active_sheet(), d.selection.active),
};
let mut row_keys: Vec<Key> = Vec::new();
let mut col_keys: Vec<Key> = Vec::new();
@@ -1146,7 +1146,7 @@ mod tests {
fn v(s: &Session, a: &str) -> Value {
let d = s.doc().unwrap();
let (sh, body) = super::split_sheet(a);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
}
fn n(s: &Session, a: &str) -> f64 {
+1 -1
View File
@@ -234,7 +234,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
/// Encodes a workbook as `kind`, whatever `path`'s extension says.
pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
let sheet = wb.active_sheet();
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
+49 -11
View File
@@ -23,6 +23,7 @@ pub mod record;
pub mod sample;
pub mod selection;
pub mod tables;
pub mod windows;
use std::sync::Arc;
@@ -39,6 +40,7 @@ pub use gridcraft_formula as formula;
pub use gridcraft_model as model;
pub use gridcraft_numfmt as numfmt;
pub use selection::Selection;
pub use windows::{Target, TargetGuard, WindowState, WindowView};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
@@ -180,6 +182,8 @@ pub struct DocState {
pub path: Option<String>,
pub saved: Arc<Workbook>,
pub revision: u64,
/// Identifies this open workbook for the rest of the session: it never changes while the
/// workbook is open (saving, renaming, reordering) and is never reused for another one.
pub uid: u64,
pub title: String,
/// Per-sheet selection memory.
@@ -269,6 +273,17 @@ impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
Self::with_calc(wb, calc, path, title, trust)
}
/// A workbook as its file saved it: the dependency graph is built but nothing is
/// evaluated, so every value is the one the file holds until an edit reaches it. `trust`
/// is as for [`DocState::new`].
pub fn from_saved(wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.rebuild(&wb);
Self::with_calc(wb, calc, path, title, trust)
}
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
@@ -295,14 +310,14 @@ impl DocState {
!Arc::ptr_eq(&self.wb, &self.saved)
}
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
self.wb.active_sheet()
}
/// Records the view state that is saved with the file (`sheetView topLeftCell`, the active
/// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the
/// active sheet's active cell. View state never makes the workbook dirty.
pub fn store_view(&mut self, tops: &[(usize, CellRef)]) {
let active = self.selection.active;
let si = self.wb.active_sheet;
let si = self.wb.active_sheet();
let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c))
|| self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active);
if !changed {
@@ -414,7 +429,15 @@ pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
/// Index in `docs` of the active window's workbook.
active: usize,
/// Windows on the open workbooks ([`windows`]); every workbook has at least one.
windows: Vec<WindowState>,
active_window: Option<u64>,
/// Last window id handed out.
next_window: u64,
/// Window activation counter (most recently used order).
activation: u64,
pub clipboard: Option<Clipboard>,
pub prefs: Prefs,
pub mode: Mode,
@@ -437,8 +460,6 @@ pub struct Session {
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// Windows hidden with View › Hide.
pub hidden_windows: Vec<usize>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
pub draw_tool: String,
pub draw_color: String,
@@ -474,14 +495,24 @@ impl Session {
pub fn active_index(&self) -> usize {
self.active
}
/// Makes the workbook at `i` active, in its most recently active window (unless the active
/// window already shows it).
pub fn set_active(&mut self, i: usize) {
if i < self.docs.len() {
self.active = i;
let Some(uid) = self.docs.get(i).map(|d| d.uid) else { return };
if self.active == i && self.active_window.and_then(|w| self.window(w)).is_some_and(|w| w.doc_uid == uid) {
return;
}
if let Some(w) = self.doc_window(uid) {
self.activate_window(w);
}
}
pub fn active(&self) -> Option<&DocState> {
self.docs.get(self.active)
}
/// Where the open workbook with this [`DocState::uid`] is in [`Session::documents`].
pub fn document_index(&self, uid: u64) -> Option<usize> {
self.docs.iter().position(|d| d.uid == uid)
}
pub fn active_mut(&mut self) -> Option<&mut DocState> {
self.docs.get_mut(self.active)
}
@@ -491,17 +522,24 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// Opens a workbook in a new window, which becomes the active one.
pub fn add_document(&mut self, d: DocState) -> usize {
let uid = d.uid;
self.docs.push(d);
self.active = self.docs.len() - 1;
self.active
let w = self.push_window(uid);
self.activate_window(w);
self.docs.len() - 1
}
/// Closes a workbook and its windows; the most recently active remaining window comes forward.
pub fn close_document(&mut self, i: usize) {
if i < self.docs.len() {
let d = self.docs.remove(i);
self.groups.retain(|g| g.doc != d.uid);
if self.active >= self.docs.len() {
self.active = self.docs.len().saturating_sub(1);
self.windows.retain(|w| w.doc_uid != d.uid);
if self.active_window.is_some_and(|w| self.window(w).is_none()) {
self.activate_recent();
} else if let Some(di) = self.active_window.and_then(|w| self.window(w)).and_then(|w| self.document_index(w.doc_uid)) {
self.active = di;
}
}
}
@@ -585,7 +623,7 @@ impl Session {
};
// The recorder keeps top-level calls with the place they ran in.
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
Some(d) => (d.uid, d.wb.active_sheet(), d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
None => (0, 0, String::new(), Selection::default()),
});
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
+1 -1
View File
@@ -37,7 +37,7 @@ pub fn build(name: &str) -> Option<Workbook> {
return None;
}
let mut wb = (*s.doc().ok()?.wb).clone();
wb.active_sheet = 0;
wb.set_active_sheet(0);
if let Some(sh) = wb.sheet_mut(0) {
sh.view_active = gridcraft_core::CellRef::default();
}
+345 -5
View File
@@ -193,7 +193,7 @@ fn chart_category_axis_from_blank_corner_and_select_data() {
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), sh.charts.last().unwrap());
assert_eq!(data.categories, ["2020", "2021", "2022"]);
// The last series can't be removed; "" clears the labels.
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
@@ -1818,7 +1818,7 @@ fn slicer_finds_a_named_table_on_another_sheet() {
s.execute("insert.table", json!({"range": "A1:B3", "name": "Sales"})).unwrap();
s.execute("home.insertSheet", json!({"name": "Report"})).unwrap();
let report = s.doc().unwrap().wb.sheet_index("Report").unwrap();
assert_eq!(s.doc().unwrap().wb.active_sheet, report);
assert_eq!(s.doc().unwrap().wb.active_sheet(), report);
// With another sheet active, `table` still names the table, and the slicer goes on the table's sheet.
let r = s.execute("insert.slicer", json!({"table": "Sales", "column": "Region", "at": "D2"})).unwrap();
@@ -2000,6 +2000,69 @@ fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
assert_eq!(v(&s, "B6"), Value::Empty);
}
fn formula_at(s: &Session, a: &str) -> Option<String> {
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
}
#[test]
fn autosum_over_a_row_totals_it_to_the_right() {
// As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total
// under each of its cells.
let mut s = s();
s.execute("range.setValues", json!({"range": "B2", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "B2:E2"})).unwrap();
assert_eq!(formula_at(&s, "F2").as_deref(), Some("SUM(B2:E2)"));
assert_eq!(v(&s, "F2"), Value::Number(10.0));
assert!(gridcraft_core::RangeRef::parse("B3:F3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "nothing under the row");
assert_eq!(s.doc().unwrap().undo.len(), 2, "one undo step");
// A row whose last cell is empty takes its total there.
s.execute("range.setValues", json!({"range": "B4", "values": [[5, 6, 7]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "B4:E4"})).unwrap();
assert_eq!(formula_at(&s, "E4").as_deref(), Some("SUM(B4:D4)"));
assert_eq!(v(&s, "F4"), Value::Empty);
// AVERAGE and the rest go the same way.
s.execute("formulas.autoSum", json!({"range": "B2:E2", "function": "average"})).unwrap();
assert_eq!(formula_at(&s, "F2").as_deref(), Some("AVERAGE(B2:E2)"));
}
#[test]
fn autosum_over_a_whole_row_totals_it_after_the_data() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A2", "values": [[1, 2, 3]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "2:2"})).unwrap();
assert_eq!(formula_at(&s, "D2").as_deref(), Some("SUM(A2:C2)"));
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.used_range().unwrap().a1(), "A2:D2", "not out to the last column");
}
#[test]
fn autosum_fills_a_selections_empty_last_column_and_row() {
// As in Excel: an empty last column takes each row's total, an empty last row each column's,
// and with both the corner takes the total of the block.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "A1:C2"})).unwrap();
assert_eq!((formula_at(&s, "C1").as_deref(), formula_at(&s, "C2").as_deref()), (Some("SUM(A1:B1)"), Some("SUM(A2:B2)")));
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(3.0), Value::Number(7.0)));
assert!(gridcraft_core::RangeRef::parse("A3:C3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "no totals under the rows");
s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2], [3, 4]]})).unwrap();
let steps = s.doc().unwrap().undo.len();
s.execute("formulas.autoSum", json!({"range": "E1:G3"})).unwrap();
for (cell, formula, value) in
[("E3", "SUM(E1:E2)", 4.0), ("F3", "SUM(F1:F2)", 6.0), ("G1", "SUM(E1:F1)", 3.0), ("G2", "SUM(E2:F2)", 7.0), ("G3", "SUM(E1:F2)", 10.0)]
{
assert_eq!(formula_at(&s, cell).as_deref(), Some(formula), "{cell}");
assert_eq!(v(&s, cell), Value::Number(value), "{cell}");
}
assert_eq!(s.doc().unwrap().undo.len(), steps + 1, "one undo step");
// Neither empty: totals below each column, as before.
s.execute("range.setValues", json!({"range": "I1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "I1:J2"})).unwrap();
assert_eq!((formula_at(&s, "I3").as_deref(), formula_at(&s, "J3").as_deref()), (Some("SUM(I1:I2)"), Some("SUM(J1:J2)")));
assert_eq!(v(&s, "K1"), Value::Empty);
}
#[test]
fn insert_and_delete_cells_never_shift_part_of_a_table() {
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
@@ -2711,7 +2774,10 @@ fn every_command_survives_empty_params() {
let mut s = Session::new();
s.execute("file.new", json!({"sample": "sales"})).unwrap();
for spec in crate::command_specs() {
if matches!(spec.id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture") {
if matches!(
spec.id,
"file.close" | "window.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture"
) {
continue;
}
let _ = s.execute(spec.id, json!({}));
@@ -2878,7 +2944,7 @@ fn error_checking_quotes_sheet_names_in_circular_references() {
assert_eq!(v(&s, "A1"), Value::Number(2.0));
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string()));
assert_eq!((d.wb.active_sheet(), d.selection.active.a1()), (1, "B2".to_string()));
}
/// Undo and redo put cells back without calculating them; the circular references listed are
@@ -3383,6 +3449,19 @@ fn absolute_objects_ignore_sort_and_structural_edits() {
assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap()));
}
/// Deleting a row removes the sparkline located on it; later ones shift up.
#[test]
fn deleting_a_row_removes_its_sparklines() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
s.execute("insert.sparkline", json!({"range": "A2:C2", "location": "E2"})).unwrap();
s.execute("insert.sparkline", json!({"range": "A3:C3", "location": "E3"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "2:2"})).unwrap();
let got: Vec<(String, String)> =
s.doc().unwrap().wb.active().unwrap().sparklines.iter().map(|sp| (sp.cell.to_string(), sp.source.clone())).collect();
assert_eq!(got, vec![("E2".to_string(), "A2:C2".to_string())]);
}
/// A "move but don't size" object follows its row across a sort but keeps its size.
#[test]
fn move_only_objects_follow_but_do_not_resize() {
@@ -3775,6 +3854,35 @@ fn notes_comments_and_links_survive_a_json_save() {
assert_eq!(after.cells, before.cells);
}
/// Writing a note or a comment on a cell that has one of the same kind edits its text: the
/// replies, the resolved state, the dates and the note's box stay. `comment` replaced the whole
/// thread, so editing a comment dropped its replies.
#[test]
fn editing_a_note_or_a_comment_keeps_its_thread() {
let mut s = s();
s.execute("review.newComment", json!({"cell": "B2", "text": "first", "author": "Ada"})).unwrap();
s.execute("review.replyComment", json!({"cell": "B2", "text": "reply", "author": "Bob"})).unwrap();
s.execute("review.resolveComment", json!({"cell": "B2", "resolved": true})).unwrap();
s.execute("review.newComment", json!({"cell": "B2", "text": "edited"})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Ada"})).unwrap();
s.execute("review.showNote", json!({"cell": "A1"})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "changed"})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let thread = &sh.comments[&CellRef::parse("B2").unwrap()];
assert_eq!((thread.author.as_str(), thread.text.as_str()), ("Ada", "edited"));
assert_eq!(thread.replies, vec![("Bob".to_string(), "reply".to_string())]);
assert!(thread.resolved && thread.threaded);
let note = &sh.comments[&CellRef::parse("A1").unwrap()];
assert_eq!((note.author.as_str(), note.text.as_str()), ("Ada", "changed"));
assert!(note.visible && !note.threaded);
// A note written over a comment replaces it.
s.execute("review.newNote", json!({"cell": "B2", "text": "plain"})).unwrap();
let d = s.doc().unwrap();
let replaced = &d.wb.active().unwrap().comments[&CellRef::parse("B2").unwrap()];
assert!(!replaced.threaded && replaced.replies.is_empty());
}
/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and
/// pictures were removed but the block's merges and notes were not, so one merged block became
/// two (an empty one left at the old place) and a note ended up on both the old and the new
@@ -4101,7 +4209,7 @@ fn line_sizes_and_freeze_panes_take_a_sheet() {
assert!(data.cols.get(&4).is_some_and(|c| c.hidden));
assert_eq!(data.default_col_width, 80.0);
assert_eq!(sheet(&s, 0), before, "the active sheet is untouched");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
s.execute("view.freezePanes", json!({"cell": "Data!B3"})).unwrap();
assert_eq!(sheet(&s, 1).freeze, Some((2, 1)));
@@ -4720,6 +4828,212 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
}
#[test]
fn switch_windows_lists_workbooks_by_stable_id_and_switches_to_one() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "first"})).unwrap();
let book1 = s.doc().unwrap().uid;
s.execute("file.new", json!({})).unwrap();
s.execute("file.new", json!({})).unwrap();
let book3 = s.doc().unwrap().uid;
let list = s.execute("view.switchWindows", json!({})).unwrap();
let rows = list.as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!((rows[0]["doc"].as_u64(), rows[0]["title"].as_str(), rows[0]["dirty"].as_bool()), (Some(book1), Some("Book1"), Some(true)));
assert_eq!((rows[2]["dirty"].as_bool(), rows[2]["active"].as_bool()), (Some(false), Some(true)));
// Closing a workbook shifts the indexes but not the ids.
s.execute("window.activate", json!({"index": 1})).unwrap();
s.execute("file.close", json!({"force": true})).unwrap();
assert_eq!(s.document_index(book3), Some(1));
let list = s.execute("view.switchWindows", json!({"doc": book1})).unwrap();
assert_eq!(s.doc().unwrap().uid, book1);
assert_eq!(v(&s, "A1"), Value::text("first"));
assert_eq!(list[0]["active"].as_bool(), Some(true));
s.execute("file.save", json!({"path": std::env::temp_dir().join(format!("gridcraft-uid-{}.xlsx", std::process::id()))})).unwrap();
assert_eq!(s.doc().unwrap().uid, book1, "saving keeps the id");
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("gridcraft-uid-{}.xlsx", std::process::id())));
assert!(s.execute("view.switchWindows", json!({"doc": book3 + 1000})).is_err());
assert!(s.execute("view.switchWindows", json!({"doc": "Book1"})).is_err());
assert_eq!(s.doc().unwrap().uid, book1, "a bad id leaves the active workbook alone");
}
#[test]
fn new_window_shares_the_workbook_and_closes_window_by_window() {
let mut s = s();
let book = s.doc().unwrap().uid;
assert_eq!(s.windows().len(), 1, "a new workbook opens in one window");
let first = s.active_window().unwrap();
assert_eq!(s.window_title(first).as_deref(), Some("Book1"));
let r = s.execute("view.newWindow", json!({})).unwrap();
let second = r["window"].as_u64().unwrap();
assert_eq!(s.documents().len(), 1, "no copy of the workbook");
assert_eq!((s.windows().len(), s.active_window()), (2, Some(second)));
assert_eq!(s.window_title(first).as_deref(), Some("Book1:1"));
assert_eq!(s.window_title(second).as_deref(), Some("Book1:2"));
assert!(!s.doc().unwrap().is_dirty());
// An edit in window 2 is in window 1's workbook, and saving from window 2 saves it.
s.execute("cell.set", json!({"cell": "A1", "input": "shared"})).unwrap();
let path = std::env::temp_dir().join(format!("gridcraft-windows-{}.xlsx", std::process::id()));
s.execute("file.save", json!({"path": path})).unwrap();
s.execute("window.activate", json!({"window": first})).unwrap();
assert_eq!((s.doc().unwrap().uid, v(&s, "A1")), (book, Value::text("shared")));
assert!(!s.doc().unwrap().is_dirty(), "saved from the other window");
assert_eq!(s.doc().unwrap().path.as_deref(), path.to_str());
let _ = std::fs::remove_file(&path);
// Closing one window keeps the workbook and renumbers the titles; the last one closes it.
s.execute("cell.set", json!({"cell": "A2", "input": "unsaved"})).unwrap();
let r = s.execute("file.close", json!({})).unwrap();
assert_eq!((r["closed"].as_bool(), r["document"].as_bool()), (Some(true), Some(false)), "no prompt for one of two windows");
assert_eq!((s.windows().len(), s.active_window()), (1, Some(second)));
assert_eq!(s.window_title(second).as_deref(), Some(s.doc().unwrap().display_title().as_str()));
let r = s.execute("window.close", json!({})).unwrap();
assert_eq!(r["closed"].as_bool(), Some(false), "the last window asks about unsaved changes");
assert_eq!(s.documents().len(), 1);
s.execute("window.close", json!({"force": true})).unwrap();
assert!(s.documents().is_empty() && s.windows().is_empty() && s.active_window().is_none());
}
#[test]
fn switching_windows_swaps_their_views_without_dirtying_the_workbook() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
let path = std::env::temp_dir().join(format!("gridcraft-window-views-{}.xlsx", std::process::id()));
s.execute("file.save", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("selection.set", json!({"range": "B2:C3"})).unwrap();
let first = s.active_window().unwrap();
let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
s.execute("selection.set", json!({"range": "D4"})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
s.execute("window.activate", json!({"window": first})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet(), d.selection.a1()), (0, "B2:C3".to_string()));
assert!(!d.is_dirty(), "a window's sheet and selection are not document changes");
s.execute("view.switchWindows", json!({"window": second})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet(), d.selection.a1()), (1, "D4".to_string()));
assert!(!d.is_dirty());
// A routed command acts in its window and leaves the active one showing.
s.execute_in(crate::Target::Window(first), "cell.set", json!({"cell": "A1", "input": "in window 1"})).unwrap();
assert_eq!(s.active_window(), Some(second));
assert_eq!(s.doc().unwrap().wb.sheets[0].value(CellRef::parse("A1").unwrap()), Value::text("in window 1"));
assert_eq!(s.doc().unwrap().selection.a1(), "D4");
}
#[test]
fn window_swaps_share_the_workbook_and_save_records_the_latest_window_sheet() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
let path = std::env::temp_dir().join(format!("gridcraft-window-arc-{}.xlsx", std::process::id()));
s.execute("file.save", json!({"path": path})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("selection.set", json!({"range": "B2"})).unwrap();
let first = s.active_window().unwrap();
let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
// Swapping windows, as the interface does before drawing each one, never copies the workbook.
let before = s.doc().unwrap().wb.clone();
for _ in 0..3 {
s.show_window(first);
assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (0, "B2".to_string()));
s.activate_window(second);
assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (1, "C3:D4".to_string()));
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
}
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &d.wb), "a window swap or sheet switch copied the workbook");
assert!(!d.is_dirty());
// A command run in window 2 edits window 2's sheet at its selection.
s.execute("cell.set", json!({"input": "two"})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets[1].value(CellRef::parse("C3").unwrap()), Value::text("two"));
assert_eq!(d.wb.sheets[0].value(CellRef::parse("B2").unwrap()), Value::Empty);
s.show_window(first);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
// The most recently active window is window 2: the file records its sheet.
s.execute_in(crate::Target::Window(second), "file.save", json!({"path": path})).unwrap();
let mut t = Session::new();
t.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(t.doc().unwrap().wb.active_sheet(), 1);
s.activate_window(first);
s.execute("file.save", json!({"path": path})).unwrap();
let mut t = Session::new();
t.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(t.doc().unwrap().wb.active_sheet(), 0);
}
#[test]
fn arrange_all_picks_the_visible_windows_active_first() {
let mut s = s();
let book1 = s.active_window().unwrap();
let book1b = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
s.execute("file.new", json!({})).unwrap();
let book2 = s.active_window().unwrap();
let r = s.execute("view.arrangeAll", json!({})).unwrap();
assert_eq!(r, json!({"arrange": "tiled", "windows": [book2, book1, book1b]}));
s.execute("window.activate", json!({"window": book1b})).unwrap();
s.execute("view.hideWindow", json!({"window": book2})).unwrap();
let r = s.execute("view.arrangeAll", json!({"arrange": "cascade", "activeWorkbook": true})).unwrap();
assert_eq!(r, json!({"arrange": "cascade", "windows": [book1b, book1]}));
assert!(s.execute("view.arrangeAll", json!({"arrange": "diagonal"})).is_err());
}
#[test]
fn hide_unhide_and_switch_by_window_id() {
let mut s = s();
let book1 = s.doc().unwrap().uid;
let w1 = s.active_window().unwrap();
let w2 = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
s.execute("file.new", json!({})).unwrap();
let w3 = s.active_window().unwrap();
s.execute("view.hideWindow", json!({})).unwrap();
assert_eq!(s.active_window(), Some(w2), "the most recent visible window comes forward");
let list = s.execute("window.list", json!({})).unwrap();
let rows = list.as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["doc"].as_u64()), (Some(w1), Some("Book1:1"), Some(book1)));
assert_eq!((rows[2]["title"].as_str(), rows[2]["hidden"].as_bool()), (Some("Book2"), Some(true)));
assert_eq!(rows[1]["active"].as_bool(), Some(true));
s.execute("view.unhideWindow", json!({})).unwrap();
assert_eq!(s.active_window(), Some(w3));
assert!(!s.window(w3).unwrap().hidden);
// A workbook id goes to that workbook's most recent window.
s.execute("view.switchWindows", json!({"doc": book1})).unwrap();
assert_eq!(s.active_window(), Some(w2));
assert!(s.execute("window.activate", json!({"window": 999})).is_err());
assert_eq!(s.active_window(), Some(w2));
}
#[test]
fn open_replaces_an_untouched_blank_workbook_and_its_windows() {
let mut s = s();
s.execute("view.newWindow", json!({})).unwrap();
let path = std::env::temp_dir().join(format!("gridcraft-open-blank-{}.csv", std::process::id()));
std::fs::write(&path, "a,b\n1,2\n").unwrap();
s.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(s.documents().len(), 1);
assert_eq!(s.windows().len(), 1, "the blank workbook's windows closed with it");
assert_eq!(s.windows()[0].doc_uid, s.doc().unwrap().uid);
}
#[test]
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
@@ -4770,6 +5084,9 @@ fn a_script_cannot_switch_windows() {
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
assert!(err.to_string().contains("window.activate"), "{err}");
s.execute("automate.newScript", json!({"name": "sw", "commands": [{"command": "view.switchWindows", "params": {"doc": 1}}]})).unwrap();
let err = s.execute("automate.runScript", json!({"name": "sw"})).expect_err("script switching windows from the View tab");
assert!(err.to_string().contains("view.switchWindows"), "{err}");
// A benign script runs.
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
@@ -4777,3 +5094,26 @@ fn a_script_cannot_switch_windows() {
}
mod seams;
#[test]
fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=10"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
// The values a file would have saved, not the ones the formulas give.
for (a, n) in [("B1", 99.0), ("C1", 7.0)] {
let sheet = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let c = CellRef::parse(a).unwrap();
let mut cell = sheet.cells.get(c).unwrap().clone();
cell.value = Value::Number(n);
sheet.set_cell(c, cell);
}
let mut saved = Session::new();
saved.add_document(crate::DocState::from_saved(wb, None, "saved".into(), crate::Trust::FromFile));
assert_eq!(v(&saved, "B1"), Value::Number(99.0));
saved.execute("cell.set", json!({"cell": "A1", "input": "3"})).unwrap();
assert_eq!(v(&saved, "B1"), Value::Number(6.0));
assert_eq!(v(&saved, "C1"), Value::Number(7.0));
}
+1 -1
View File
@@ -837,7 +837,7 @@ fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
assert_eq!(recorded_ids(&s), ["cell.set", "view.customViews", "view.customViews"]);
let first = &s.recorder.as_ref().unwrap().steps[0];
let d = s.doc().unwrap();
assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet));
assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet()));
assert_eq!(first.sheet_name, d.wb.active().unwrap().name);
assert_eq!(first.selection.a1(), "B2:C3", "the selection before the command ran");
assert_eq!(first.params, json!({"cell": "B2", "input": "10"}));
+348
View File
@@ -0,0 +1,348 @@
//! Windows on open workbooks.
//!
//! Every open workbook has one or more windows (View › New Window adds another on the same
//! workbook). A window has its own selection, active sheet and per-sheet selections. The active
//! window's view lives in its [`DocState`] (`selection`, `sheet_selections`) and in the workbook's
//! active sheet ([`gridcraft_model::workbook::Workbook::active_sheet`]), where every command reads
//! it; each other window keeps its view in [`WindowState`] until it is activated. Swapping a view
//! in or out never copies the workbook or makes it dirty: the active sheet is set in place through
//! the shared `Arc`, and selections live outside the workbook.
use serde_json::{Value as Json, json};
use crate::{DocState, EngineError, Result, Selection, Session};
/// The view state one window keeps while another window on its workbook is showing.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WindowView {
pub selection: Selection,
pub sheet: usize,
/// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another
/// window still finds it.
pub sheet_name: String,
pub sheet_selections: Vec<Selection>,
}
/// One window on an open workbook.
#[derive(Clone, Debug)]
pub struct WindowState {
/// Identifies the window for the rest of the session; never reused.
pub id: u64,
/// The [`DocState::uid`] of the workbook it shows.
pub doc_uid: u64,
/// View › Hide.
pub hidden: bool,
/// `None` while this window's view is the one in the document (the active window, or the
/// workbook's most recently active window); exactly one window per workbook has `None`.
view: Option<WindowView>,
/// Activation order: higher was active more recently.
last_active: u64,
}
impl WindowView {
fn capture(d: &DocState) -> WindowView {
WindowView {
selection: d.selection.clone(),
sheet: d.wb.active_sheet(),
sheet_name: d.wb.sheets.get(d.wb.active_sheet()).map(|s| s.name.clone()).unwrap_or_default(),
sheet_selections: d.sheet_selections.clone(),
}
}
fn apply(self, d: &mut DocState) {
let sheets = &d.wb.sheets;
let at = if sheets.get(self.sheet).is_some_and(|s| s.name == self.sheet_name) {
self.sheet
} else {
sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.sheet)
};
d.wb.set_active_sheet(at.min(sheets.len().saturating_sub(1)));
d.selection = self.selection;
d.sheet_selections = self.sheet_selections;
}
}
/// Which window a command from a script or agent acts on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Target {
/// The active window.
#[default]
Active,
/// A window by [`WindowState::id`].
Window(u64),
/// A workbook by [`DocState::uid`]: its most recently active window.
Doc(u64),
}
impl Target {
/// Reads `window` or `doc` from a JSON object (control channel envelopes, MCP arguments).
/// Neither present is [`Target::Active`]; a value that isn't an id is an error.
pub fn from_json(p: &Json) -> Result<Target> {
let id = |k: &str| -> Result<Option<u64>> {
match p.get(k) {
None | Some(Json::Null) => Ok(None),
Some(v) => v.as_u64().map(Some).ok_or_else(|| EngineError::Other(format!("`{k}` must be a window or workbook id number"))),
}
};
Ok(match (id("window")?, id("doc")?) {
(Some(w), _) => Target::Window(w),
(None, Some(d)) => Target::Doc(d),
(None, None) => Target::Active,
})
}
}
/// What [`Session::leave_target`] restores.
#[derive(Debug)]
#[must_use]
pub struct TargetGuard {
id: u64,
back: Option<u64>,
}
impl Session {
/// The open windows, in the order they were opened.
pub fn windows(&self) -> &[WindowState] {
&self.windows
}
/// The active window's id (`None` with no workbook open).
pub fn active_window(&self) -> Option<u64> {
self.active_window
}
pub fn window(&self, id: u64) -> Option<&WindowState> {
self.windows.iter().find(|w| w.id == id)
}
/// A workbook's windows in opening order.
fn windows_of(&self, doc: u64) -> impl Iterator<Item = &WindowState> {
self.windows.iter().filter(move |w| w.doc_uid == doc)
}
/// The window title: the workbook's name when it has one window, "Name:1", "Name:2"… in
/// opening order when it has several.
pub fn window_title(&self, id: u64) -> Option<String> {
let w = self.window(id)?;
let d = self.docs.iter().find(|d| d.uid == w.doc_uid)?;
let name = d.display_title();
let mut n = 0;
let mut at = 0;
for x in self.windows_of(w.doc_uid) {
n += 1;
if x.id == id {
at = n;
}
}
Some(if n > 1 { format!("{name}:{at}") } else { name })
}
/// The most recently active window of a workbook, preferring one that isn't hidden.
pub fn doc_window(&self, doc: u64) -> Option<u64> {
self.windows_of(doc).max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id)
}
/// Resolves a [`Target`] to a window id.
pub fn target_window(&self, t: Target) -> Result<u64> {
match t {
Target::Active => self.active_window.ok_or(EngineError::NoDocument),
Target::Window(id) => self.window(id).map(|w| w.id).ok_or_else(|| EngineError::Other(format!("no open window has id {id}"))),
Target::Doc(uid) => self.doc_window(uid).ok_or_else(|| EngineError::Other(format!("no open workbook has id {uid}"))),
}
}
pub(crate) fn push_window(&mut self, doc_uid: u64) -> u64 {
self.next_window = self.next_window.saturating_add(1);
let id = self.next_window;
self.windows.push(WindowState { id, doc_uid, hidden: false, view: None, last_active: 0 });
id
}
/// Makes a window active, swapping its view into its workbook. Unknown ids are ignored.
pub fn activate_window(&mut self, id: u64) {
self.enter_window(id, true);
}
/// Makes a window the one commands act on while the interface draws it, without counting as
/// an activation: the most recently active window of each workbook stays the same.
pub fn show_window(&mut self, id: u64) {
self.enter_window(id, false);
}
fn enter_window(&mut self, id: u64, recent: bool) {
let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
let Some(w) = self.windows.get(wi) else { return };
let doc = w.doc_uid;
if w.view.is_some()
&& let Some(di) = self.document_index(doc)
{
let live = self.windows.iter().position(|x| x.doc_uid == doc && x.view.is_none());
let incoming = self.windows.get_mut(wi).and_then(|w| w.view.take());
if let Some(d) = self.docs.get_mut(di) {
if let Some(li) = live
&& let Some(lw) = self.windows.get_mut(li)
{
lw.view = Some(WindowView::capture(d));
}
if let Some(v) = incoming {
v.apply(d);
}
}
}
if recent {
self.activation = self.activation.saturating_add(1);
let stamp = self.activation;
if let Some(w) = self.windows.get_mut(wi) {
w.last_active = stamp;
}
}
self.active_window = Some(id);
if let Some(di) = self.document_index(doc) {
self.active = di;
}
}
/// Activates the most recently active window that isn't hidden, else any window, else none.
pub(crate) fn activate_recent(&mut self) {
let next = self.windows.iter().max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id);
match next {
Some(id) => self.activate_window(id),
None => {
self.active_window = None;
self.active = 0;
}
}
}
/// View › New Window: another window on the active window's workbook, showing what it shows.
pub fn new_window(&mut self) -> Result<u64> {
let current = self.active_window.ok_or(EngineError::NoDocument)?;
let doc = self.window(current).map(|w| w.doc_uid).ok_or(EngineError::NoDocument)?;
let view = self.doc().map(WindowView::capture)?;
// The new window takes over the live view (identical at this point); the old one keeps a copy.
if let Some(w) = self.windows.iter_mut().find(|w| w.doc_uid == doc && w.view.is_none()) {
w.view = Some(view);
}
let id = self.push_window(doc);
self.activate_window(id);
Ok(id)
}
/// Closes one window. The workbook stays open while it has another window; closing its last
/// window closes the workbook (unsaved changes are the caller's to ask about).
pub fn close_window(&mut self, id: u64) {
let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
let doc = self.windows.get(wi).map(|w| w.doc_uid).unwrap_or_default();
if self.windows_of(doc).count() <= 1 {
if let Some(di) = self.document_index(doc) {
self.close_document(di);
}
return;
}
let closed = self.windows.remove(wi);
if closed.view.is_none() {
// Its view was the one in the document: the workbook's next window takes it over.
if let Some(next) = self.windows.iter().filter(|w| w.doc_uid == doc).max_by_key(|w| w.last_active).map(|w| w.id) {
let incoming = self.windows.iter_mut().find(|w| w.id == next).and_then(|w| w.view.take());
if let Some(v) = incoming
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == doc)
{
v.apply(d);
}
}
}
if self.active_window == Some(id) {
self.activate_recent();
}
}
/// View › Hide on a window; the most recent visible window comes forward.
pub fn hide_window(&mut self, id: u64) {
let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return };
w.hidden = true;
if self.active_window == Some(id) && self.windows.iter().any(|w| !w.hidden) {
self.activate_recent();
}
}
/// View › Unhide: shows a hidden window and activates it.
pub fn unhide_window(&mut self, id: u64) {
let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return };
w.hidden = false;
self.activate_window(id);
}
/// The most recently hidden window.
pub fn last_hidden_window(&self) -> Option<u64> {
self.windows.iter().filter(|w| w.hidden).max_by_key(|w| w.last_active).map(|w| w.id)
}
/// The windows as scripts and the Switch Windows lists see them:
/// `[{window, doc, index, title, dirty, active, hidden}]`, `index` being the workbook's
/// position in [`Session::documents`].
pub fn window_list(&self) -> Json {
Json::Array(
self.windows
.iter()
.filter_map(|w| {
let index = self.document_index(w.doc_uid)?;
let d = self.docs.get(index)?;
Some(json!({
"window": w.id,
"doc": w.doc_uid,
"index": index,
"title": self.window_title(w.id).unwrap_or_default(),
"dirty": d.is_dirty(),
"active": self.active_window == Some(w.id),
"hidden": w.hidden,
}))
})
.collect(),
)
}
/// Runs `f` with the target window active, then brings back the window that was active, so a
/// script acting on another window doesn't change what the user is looking at. When `f`
/// itself switched windows (`window.activate`, `file.new`, a close), its choice stands.
pub fn with_target<T>(&mut self, t: Target, f: impl FnOnce(&mut Session) -> T) -> Result<T> {
let guard = self.enter_target(t)?;
let r = f(self);
self.leave_target(guard);
Ok(r)
}
/// The first half of [`Session::with_target`], for callers whose closure can't borrow the
/// session alone (the UI runs commands through its app): activates the target window and
/// returns what [`Session::leave_target`] needs to bring the previous one back.
pub fn enter_target(&mut self, t: Target) -> Result<Option<TargetGuard>> {
if t == Target::Active {
return Ok(None);
}
let id = self.target_window(t)?;
let back = self.active_window;
self.activate_window(id);
Ok(Some(TargetGuard { id, back }))
}
pub fn leave_target(&mut self, guard: Option<TargetGuard>) {
let Some(TargetGuard { id, back: Some(back) }) = guard else { return };
if self.active_window == Some(id) && back != id && self.window(back).is_some() {
self.activate_window(back);
}
}
/// [`Session::execute`] on a target window.
pub fn execute_in(&mut self, t: Target, id: &str, params: Json) -> Result<Json> {
self.with_target(t, |s| s.execute(id, params))?
}
/// [`Session::execute_headless`] on a target window.
pub fn execute_headless_in(&mut self, t: Target, id: &str, params: Json) -> Result<Json> {
self.with_target(t, |s| s.execute_headless(id, params))?
}
}
/// Commands that take `window`/`doc` as their own parameters rather than as a target.
pub fn is_window_command(id: &str) -> bool {
id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow" | "view.switchWindows")
}
@@ -59,7 +59,8 @@ const EXCLUDED: &[(&str, &str)] = &[
("file.exportPdf", "renders a PDF: slow, and may write files"),
("file.print", "renders a PDF: slow"),
("window.activate", "switches documents"),
("view.newWindow", "opens another document"),
("window.close", "closes the document under test"),
("view.newWindow", "opens another window, whose view swaps with the first"),
("insert.picture", "reads files"),
("pageLayout.background", "reads files"),
("formulas.calculationOptions", "manual and iterative calculation differ from a full recalc by design"),
@@ -372,8 +373,8 @@ fn structure_problem(wb: &Workbook, sel: &gridcraft_engine::Selection) -> Option
if wb.sheets.is_empty() {
return Some("no sheets left".into());
}
if wb.active_sheet >= wb.sheets.len() {
return Some(format!("active sheet {} of {}", wb.active_sheet, wb.sheets.len()));
if wb.active_sheet() >= wb.sheets.len() {
return Some(format!("active sheet {} of {}", wb.active_sheet(), wb.sheets.len()));
}
let mut tables = BTreeSet::new();
for (i, sh) in wb.sheets.iter().enumerate() {
@@ -489,7 +490,7 @@ fn watch_list(wb: &Workbook) -> Option<&str> {
/// reset: undo restores view state as it was, but switching sheets isn't an undoable edit.
fn canonical(wb: &Workbook) -> Workbook {
let mut wb = wb.clone();
wb.active_sheet = 0;
wb.set_active_sheet(0);
wb.names.retain(|n| n.name != WATCH_NAME);
for sh in wb.sheets.iter_mut() {
let sh = Arc::make_mut(sh);
@@ -0,0 +1,29 @@
//! A font a formatting command derives from the theme's body font keeps following it (#2517).
use std::io::Read;
use gridcraft_engine::Session;
use serde_json::json;
fn styles_xml(session: &Session) -> String {
let bytes = gridcraft_xlsx::write_xlsx(&session.doc().unwrap().wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
#[test]
fn derived_body_font_keeps_minor_scheme() {
let mut session = Session::new();
session.new_workbook();
session.execute("home.bold", json!({"range": "A1"})).unwrap();
session.execute("home.fontSize", json!({"range": "B1", "size": 14})).unwrap();
let xml = styles_xml(&session);
let fonts = xml.split("<fonts").nth(1).unwrap().split("</fonts>").next().unwrap();
let fonts: Vec<&str> = fonts.split("<font>").skip(1).collect();
assert!(fonts.len() >= 3, "{xml}");
for f in &fonts {
assert!(f.contains("<scheme val=\"minor\"/>"), "every body-font font follows the theme: {f}");
}
}
@@ -25,7 +25,7 @@ fn next_stays_on_the_last_visible_sheet() {
for _ in 0..2 {
session.execute("sheet.next", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 1);
assert_eq!(doc.wb.active_sheet(), 1);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
@@ -36,7 +36,7 @@ fn previous_stays_on_the_first_visible_sheet() {
for _ in 0..2 {
session.execute("sheet.previous", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 2);
assert_eq!(doc.wb.active_sheet(), 2);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
@@ -45,10 +45,10 @@ fn previous_stays_on_the_first_visible_sheet() {
fn navigation_skips_hidden_sheets_in_both_directions() {
let mut session = workbook(&[1, 2], 0);
session.execute("sheet.next", json!({})).unwrap();
assert_eq!(session.active().unwrap().wb.active_sheet, 3);
assert_eq!(session.active().unwrap().wb.active_sheet(), 3);
session.execute("sheet.previous", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 0);
assert_eq!(doc.wb.active_sheet(), 0);
assert_eq!(doc.selection.active.a1(), "C7");
}
@@ -58,7 +58,7 @@ fn navigation_with_one_visible_sheet_is_a_noop() {
for command in ["sheet.previous", "sheet.next"] {
session.execute(command, json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 1);
assert_eq!(doc.wb.active_sheet(), 1);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
@@ -73,6 +73,6 @@ fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() {
}
for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] {
session.execute(command, json!({})).unwrap();
assert_eq!(session.active().unwrap().wb.active_sheet, expected);
assert_eq!(session.active().unwrap().wb.active_sheet(), expected);
}
}
+1
View File
@@ -11,6 +11,7 @@ mod custom_filter_blanks;
mod custom_view_freeze;
mod dynamicmetadata;
mod extreme_selection_moves;
mod font_scheme_minor;
mod format_as_table_restyle;
mod hidden_sheet_navigation;
mod ink_roundtrip;
+6 -2
View File
@@ -338,12 +338,16 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
push(&mut out, Tok::Sheet(w, None), j + 1);
i = j + 1;
} else if ch(j) == Some(':') && {
// Sheet1:Sheet3!A1 — look ahead for word then '!'
// Sheet1:Sheet3!A1 — look ahead for word then '!'.
let mut k = j + 1;
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('!')
// `Sheet1!A1:Sheet1!B2` is a range whose second end repeats the sheet, not a span.
let repeats_sheet = is_range_end(&w)
&& matches!(out.last(), Some(Token { tok: Tok::Sheet(p, None), .. })
if chars.get(j + 1..k).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::<String>(), p)));
k > j + 1 && ch(k) == Some('!') && !repeats_sheet
} {
let mut k = j + 1;
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
+15
View File
@@ -5,6 +5,15 @@ fn rt(src: &str) -> String {
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
}
#[test]
fn range_end_repeating_sheet_name_round_trips() {
assert_eq!(rt("'a'!A1:'a'!B2"), "a!A1:a!B2");
assert_eq!(rt("Sheet1!A1:Sheet1!B2"), "Sheet1!A1:Sheet1!B2");
assert_eq!(rt("SUM(a!A1:a!B2)"), "SUM(a!A1:a!B2)");
// Sheet names match without regard to case, as Excel's do.
assert_eq!(rt("Sheet1!A1:SHEET1!B2"), "Sheet1!A1:SHEET1!B2");
}
#[test]
fn quoted_sheet_qualified_name_keeps_quotes() {
assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total");
@@ -503,3 +512,9 @@ fn number_literal_too_large_for_f64_is_a_parse_error() {
}
assert!(parse("1E+308").is_ok());
}
#[test]
fn three_d_span_and_cell_start_still_lex_as_span() {
assert_eq!(rt("SUM(Sheet1:Sheet3!A1)"), "SUM(Sheet1:Sheet3!A1)");
assert_eq!(rt("SUM(Q1:Sheet3!A1)"), "SUM('Q1:Sheet3'!A1)");
}
+35 -11
View File
@@ -123,8 +123,16 @@ fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
fn isoweek(sys: DateSystem, s: i64) -> R<i64> {
let iso_wd = (weekday0(sys, s) + 6) % 7 + 1; // Monday = 1
let thursday = s - iso_wd as i64 + 4;
let y = if thursday < 1 { 1900 } else { dt(sys, thursday as f64)?.year as i64 };
let jan1 = ymd_serial(sys, y, 1, 1)?;
let first = match sys {
DateSystem::D1900 => 1,
DateSystem::D1904 => 0,
};
let jan1 = if thursday < first {
// The week belongs to the year before the calendar starts.
first - 365
} else {
ymd_serial(sys, dt(sys, thursday as f64)?.year as i64, 1, 1)?
};
Ok((thursday - jan1).div_euclid(7) + 1)
}
@@ -441,18 +449,14 @@ pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R<f6
Ok(match basis {
0 => {
let (mut a, mut b) = (d1.day as i64, d2.day as i64);
if a == 31 && b == 31 {
a = 30;
if last_of_feb(&d1) && last_of_feb(&d2) {
b = 30;
} else if a == 31 {
}
if last_of_feb(&d1) || a == 31 {
a = 30;
} else if a == 30 && b == 31 {
}
if a == 30 && b == 31 {
b = 30;
} else if last_of_feb(&d1) && last_of_feb(&d2) {
a = 30;
b = 30;
} else if last_of_feb(&d1) {
a = 30;
}
((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0
}
@@ -811,6 +815,13 @@ mod tests {
close(ev("YEARFRAC", vec![s, n(d(2012.0, 1.0, 1.0))]), 0.0);
}
#[test]
fn yearfrac_basis0_feb_end_to_31() {
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0);
}
#[test]
fn date_lifting() {
let v = ev("YEAR", vec![av(row(&[43831.0, 46302.0]))]);
@@ -844,6 +855,19 @@ mod tests {
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
}
#[test]
fn isoweek_at_calendar_start() {
let iso = crate::lookup("ISOWEEKNUM").unwrap();
let wk = crate::lookup("WEEKNUM").unwrap();
let mut ctx = TestCtx::default();
close(crate::call(iso, &[n(1.0)], &mut ctx), 52.0);
close(crate::call(wk, &[n(1.0), n(21.0)], &mut ctx), 52.0);
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
for s in [0.0, 1.0, 2.0] {
close(crate::call(iso, &[n(s)], &mut ctx), 53.0);
}
}
#[test]
fn date_text_arguments_use_the_workbook_date_system() {
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
+10 -1
View File
@@ -710,7 +710,12 @@ im1!(imexp, |z: Cx| Ok(z.exp()));
im1!(imln, |z: Cx| z.ln());
im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10)));
im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2)));
im1!(imsqrt, |z: Cx| z.powf(0.5));
im1!(imsqrt, |z: Cx| {
let m = z.abs();
let re = ((m + z.re) / 2.0).sqrt();
let im = ((m - z.re) / 2.0).sqrt();
Ok(Cx::new(re, if z.im < 0.0 { -im } else { im }))
});
im1!(imsin, |z: Cx| Ok(z.sin()));
im1!(imcos, |z: Cx| Ok(z.cos()));
im1!(imtan, |z: Cx| z.sin().div(z.cos()));
@@ -1271,6 +1276,10 @@ mod tests {
cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8);
cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3);
cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0);
is_text(ev("IMSQRT", vec![t("-4")]), "2i");
is_text(ev("IMSQRT", vec![t("-9")]), "3i");
is_text(ev("IMSQRT", vec![t("4")]), "2");
is_text(ev("IMSQRT", vec![t("-2i")]), "1-i");
is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i");
cx(ev("IMPOWER", vec![t("2+3i"), n(0.5)]), 1.674_149_228_035_540_2, 0.895_977_476_129_838);
cx(ev("IMSIN", vec![t("3+4i")]), 3.853_738_037_919_377, -27.016_813_258_003_93);
+14 -1
View File
@@ -918,7 +918,9 @@ fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
}
}
let inv = inv.into_iter().map(|x| if x.abs() < 1e-15 { 0.0 } else { x }).collect();
// Rounding noise in the inverse scales with 1/scale of the input matrix.
let noise = 1e-15 / scale;
let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect();
to_value_array(n, n, inv)
}
@@ -1622,6 +1624,17 @@ mod tests {
is_text(ev2("ROMAN", 99.0, 4.0), "IC");
}
#[test]
fn minverse_scale_relative() {
let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]);
let r = rows_of(&big);
close_tol(r[0][0].clone(), 1e-20, 1e-25);
close_tol(r[1][1].clone(), 1e-20, 1e-25);
let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]);
let r = rows_of(&small);
close_tol(r[0][0].clone(), 1e20, 1e10);
}
#[test]
fn matrices() {
let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]);
+8
View File
@@ -256,6 +256,9 @@ fn intercept(y: &[f64], x: &[f64]) -> R<f64> {
fn trunc_digits(r: f64, sig: f64) -> f64 {
let f = 10f64.powi(sig as i32);
if !f.is_finite() {
return r;
}
((r * f) + 1e-9).floor() / f
}
@@ -2486,6 +2489,11 @@ mod tests {
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7);
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381);
close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3);
let t = c(&[1.0, 2.0, 3.0]);
close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0);
close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5);
}
#[test]
+32 -3
View File
@@ -25,14 +25,22 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024;
/// A running GridCraft app, reached through its loopback control port.
pub struct Remote {
addr: String,
token: String,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
}
impl Remote {
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app
/// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port.
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
Self::connect_with_token(addr, token)
}
/// Like [`Remote::connect`] with a token the caller already has.
pub fn connect_with_token(addr: &str, token: String) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 };
r.reconnect()?;
Ok(r)
}
@@ -51,7 +59,9 @@ impl Remote {
s.set_read_timeout(Some(IO_TIMEOUT)).ok();
s.set_write_timeout(Some(IO_TIMEOUT)).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
let mut conn = (BufReader::new(read), s);
Self::authenticate(&mut conn, &self.token)?;
self.conn = Some(conn);
return Ok(());
}
Err(e) => last = e,
@@ -60,6 +70,25 @@ impl Remote {
Err(last)
}
/// The handshake every connection starts with: the app closes one that doesn't send the token first.
fn authenticate(conn: &mut (BufReader<TcpStream>, TcpStream), token: &str) -> std::io::Result<()> {
let (reader, writer) = conn;
writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut reply = String::new();
let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?;
let ok = n > 0 && serde_json::from_str::<Value>(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true);
if ok {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)",
))
}
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.reconnect()?;
+135
View File
@@ -0,0 +1,135 @@
//! Authentication of the control channel (#777).
//!
//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:<port>`, can
//! reach a loopback port. So the first line of every connection must be
//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The
//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it
//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places.
use std::path::PathBuf;
use serde_json::{Value, json};
/// Environment variable holding a token chosen by the user. The app uses it instead of making
/// one (and writes no file); clients use it instead of reading the file.
pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN";
/// The method of the handshake line.
pub const AUTH_METHOD: &str = "auth";
/// Shortest token the app accepts from the user (a made one has 256 bits).
pub const MIN_TOKEN_LEN: usize = 16;
/// Longest first line the app reads from a peer that hasn't authenticated yet.
pub const MAX_AUTH_LINE: usize = 4096;
/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal
/// at once (the length of a token isn't secret).
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
// Keep the optimiser from turning the loop into an early exit.
std::hint::black_box(diff) == 0
}
/// A made token as lowercase hex (two characters per byte).
pub fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len().saturating_mul(2));
for b in bytes {
s.push(char::from(DIGITS[usize::from(b >> 4)]));
s.push(char::from(DIGITS[usize::from(b & 15)]));
}
s
}
/// The handshake line a client sends first.
pub fn auth_line(id: u64, token: &str) -> String {
json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
}
/// Check a connection's first line. `Ok` is the reply to send before serving further requests;
/// `Err` is the reply for a refused connection, which the caller sends and then closes. The
/// refusal is the same whatever was wrong, so it tells a stranger nothing.
pub fn check_first_line(line: &str, token: &str) -> Result<Value, Value> {
let refused = || json!({"ok": false, "error": "authentication required"});
let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?;
if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
return Err(refused());
}
let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or("");
// Always compare, so a missing token costs the same as a wrong one.
if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() {
Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}}))
} else {
Err(refused())
}
}
/// Where the app writes the token it made for `port`, and where clients look for it: a folder
/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows
/// `%USERPROFILE%\.gridcraft`), file `control-<port>.token`. `None` when no such folder is known.
pub fn token_path(port: u16) -> Option<PathBuf> {
let dir = if cfg!(windows) {
PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft")
} else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
PathBuf::from(run).join("gridcraft")
} else {
PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft")
};
Some(dir.join(format!("control-{port}.token")))
}
/// The port of a control address (`127.0.0.1:7979` → 7979).
fn port_of(addr: &str) -> Option<u16> {
addr.rsplit(':').next()?.parse().ok()
}
/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote.
pub fn client_token(addr: &str) -> Result<String, String> {
if let Ok(t) = std::env::var(TOKEN_ENV)
&& !t.trim().is_empty()
{
return Ok(t.trim().to_string());
}
let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?;
match std::fs::read_to_string(&path) {
Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
_ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())),
}
}
/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix),
/// an older file is replaced, and the new one is created readable by the user only (mode 0600).
/// Returns the path.
pub fn write_token_file(port: u16, token: &str) -> std::io::Result<PathBuf> {
use std::io::Write;
let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?;
let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?;
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is.
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
// Replace rather than open: a leftover file may have looser permissions, or be a link.
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&path)?;
f.write_all(token.as_bytes())?;
f.write_all(b"\n")?;
Ok(path)
}
+19 -5
View File
@@ -1,6 +1,6 @@
//! An in-process engine session that answers the engine-level control-channel methods itself.
use gridcraft_engine::Session;
use gridcraft_engine::{Session, Target};
use serde_json::{Value, json};
use crate::backend::Backend;
@@ -33,9 +33,9 @@ impl Headless {
Self { session }
}
fn execute(&mut self, id: &str, params: Value) -> Result<Value, String> {
fn execute(&mut self, target: Target, id: &str, params: Value) -> Result<Value, String> {
// 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())
self.session.execute_headless_in(target, id, params).map_err(|e| e.to_string())
}
fn commands(&self, params: &Value) -> Value {
@@ -68,14 +68,28 @@ impl Backend for Headless {
if !(p.is_object() || p.is_null()) {
return Err(format!("engine.execute: `params` of `{id}` must be an object"));
}
self.execute(&id, p)
let target = Target::from_json(&params).map_err(|e| e.to_string())?;
self.execute(target, &id, p)
}
"engine.commands" => Ok(self.commands(&params)),
"engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())),
m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")),
// Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are
// commands with the same id.
m if gridcraft_engine::find_command(m).is_some() => self.execute(m, params),
// Their params may name a `window` or `doc` to act in, except for the window commands,
// whose own params those are.
m if gridcraft_engine::find_command(m).is_some() => {
if gridcraft_engine::windows::is_window_command(m) {
return self.execute(Target::Active, m, params);
}
let target = Target::from_json(&params).map_err(|e| e.to_string())?;
let mut params = params;
if let Some(o) = params.as_object_mut() {
o.remove("window");
o.remove("doc");
}
self.execute(target, m, params)
}
other => Err(format!("unknown method `{other}`")),
}
}
+1
View File
@@ -15,6 +15,7 @@
#![forbid(unsafe_code)]
mod backend;
pub mod control_auth;
mod headless;
mod server;
mod tools;
+78 -2
View File
@@ -106,6 +106,7 @@ fn tools_list_has_schemas() {
"redo",
"screenshot",
"list_functions",
"list_windows",
] {
assert!(names.contains(&want), "missing tool {want}");
}
@@ -366,6 +367,11 @@ fn remote_backend_over_tcp() {
let stream = stream.unwrap();
let mut w = stream.try_clone().unwrap();
let mut r = BufReader::new(stream);
// Every connection starts with the token, as the app requires.
let mut first = String::new();
r.read_line(&mut first).unwrap();
let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap();
w.write_all(format!("{hello}\n").as_bytes()).unwrap();
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap_or(0) == 0 {
@@ -385,7 +391,7 @@ fn remote_backend_over_tcp() {
});
let addr = control_addr(&port.to_string());
assert_eq!(addr, format!("127.0.0.1:{port}"));
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap()));
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0));
// Second call reconnects transparently.
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"}));
@@ -394,7 +400,49 @@ fn remote_backend_over_tcp() {
assert!(msg.contains("unknown command"), "{msg}");
drop(s);
handle.join().unwrap();
assert!(Remote::connect("127.0.0.1:1").is_err());
assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err());
}
#[test]
fn control_auth_handshake_and_compare() {
use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex};
let token = "0123456789abcdef0123456789abcdef";
let ok = check_first_line(&auth_line(7, token), token).unwrap();
assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true)));
// Wrong or missing token, another method, and non-JSON are all refused the same way.
let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err();
for bad in [
auth_line(1, ""),
r#"{"id":1,"method":"auth","params":{}}"#.into(),
r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(),
"POST / HTTP/1.1".into(),
String::new(),
] {
assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}");
}
// An empty configured token never matches an empty given one.
assert!(check_first_line(&auth_line(1, ""), "").is_err());
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff");
}
#[cfg(unix)]
#[test]
fn control_token_file_is_private() {
use std::os::unix::fs::PermissionsExt;
// Unique port per run so parallel tests don't share a file; the folder is the real one.
let port = 40_000 + (std::process::id() % 20_000) as u16;
let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700);
// A looser leftover is replaced, not reused.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap();
crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef");
std::fs::remove_file(&path).unwrap();
}
#[test]
@@ -548,3 +596,31 @@ fn invalid_sort_keys_are_invalid_params() {
}
ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]}));
}
#[test]
fn a_window_argument_routes_tools_to_that_window() {
let mut s = server();
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "book one"}));
ok(&mut s, "new_workbook", json!({}));
let windows = ok(&mut s, "list_windows", json!({}));
let rows = windows.as_array().unwrap();
assert_eq!(rows.len(), 2, "{windows}");
let (w1, book1) = (rows[0]["window"].as_u64().unwrap(), rows[0]["doc"].as_u64().unwrap());
assert_eq!((rows[0]["title"].as_str(), rows[1]["active"].as_bool()), (Some("Book1"), Some(true)));
// Book2 stays active; reads and writes go to Book1's window.
ok(&mut s, "set_cell", json!({"cell": "B1", "input": "=A1", "window": w1}));
let got = ok(&mut s, "get_cell", json!({"cell": "B1", "doc": book1}));
assert!(got.to_string().contains("book one"), "{got}");
let book2 = ok(&mut s, "read_range", json!({"range": "A1:B1"}));
assert!(!book2.to_string().contains("book one"), "{book2}");
let info = ok(&mut s, "inspect_workbook", json!({"window": w1}));
assert!(info.to_string().contains("Book1"), "{info}");
assert!(fails(&mut s, "get_cell", json!({"cell": "A1", "window": 999})).contains("no open window"));
// A second window on Book1 gets a numbered title.
ok(&mut s, "execute_command", json!({"command": "view.newWindow", "window": w1}));
let titles: Vec<String> =
ok(&mut s, "list_windows", json!({})).as_array().unwrap().iter().map(|r| r["title"].as_str().unwrap().to_string()).collect();
assert_eq!(titles, ["Book1:1", "Book2", "Book1:2"]);
}
+62 -2
View File
@@ -106,8 +106,26 @@ const BORDER_PRESETS: &[&str] = &[
"diagonalUp",
];
/// All tools, in `tools/list` order.
/// Tools that don't act on one workbook, so they take no `window`/`doc` target.
const UNTARGETED: &[&str] = &["list_commands", "list_functions", "list_windows", "new_workbook", "open_workbook", "screenshot"];
/// All tools, in `tools/list` order. Tools that act on a workbook take an optional `window` or
/// `doc` id (from list_windows) and default to the active window.
pub fn tool_definitions() -> Vec<Value> {
let mut tools = base_tool_definitions();
for t in &mut tools {
if UNTARGETED.iter().any(|n| t["name"] == *n) {
continue;
}
if let Some(props) = t["inputSchema"]["properties"].as_object_mut() {
props.insert("window".into(), int("Window id from list_windows (default: the active window)"));
props.insert("doc".into(), int("Workbook id from list_windows: its most recent window (default: the active window)"));
}
}
tools
}
fn base_tool_definitions() -> Vec<Value> {
let empty = || obj(json!({}), &[]);
vec![
// ----- generic -----
@@ -125,6 +143,14 @@ pub fn tool_definitions() -> Vec<Value> {
),
false,
),
tool(
"list_windows",
"List windows",
"List the open windows: window id, workbook (`doc`) id, title (\"Book1\", or \"Book1:2\" for a workbook's second \
window), unsaved changes, active and hidden. Pass a `window` or `doc` id to other tools to act there.",
empty(),
true,
),
tool(
"list_commands",
"List commands",
@@ -608,6 +634,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<Value, String>
exec(b, cmd, p)
}
"list_commands" => b.call("engine.commands", pick(a, &["search"])),
"list_windows" => exec(b, "window.list", json!({})),
"inspect_workbook" => b.call("document.inspect", json!({})),
"read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])),
"write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])),
@@ -638,6 +665,35 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<Value, String>
}
}
/// Sends every engine call of one tool to the window its `window`/`doc` argument names.
struct Targeted<'a> {
inner: &'a mut dyn Backend,
target: Map<String, Value>,
}
impl Backend for Targeted<'_> {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let envelope = if method == "engine.execute" {
params
} else if gridcraft_engine::find_command(method).is_some() {
json!({"command": method, "params": params})
} else {
return self.inner.call(method, params);
};
let mut envelope = envelope;
if let Some(o) = envelope.as_object_mut() {
o.extend(self.target.clone());
}
self.inner.call("engine.execute", envelope)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
}
/// Runs one tool. Failures become `isError` results with a readable message.
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
let empty = Map::new();
@@ -646,7 +702,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
Value::Null => &empty,
_ => return ToolResult::error("tool arguments must be a JSON object"),
};
match dispatch(b, name, a) {
let target: Map<String, Value> =
["window", "doc"].iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect();
let r =
if target.is_empty() || UNTARGETED.contains(&name) { dispatch(b, name, a) } else { dispatch(&mut Targeted { inner: b, target }, name, a) };
match r {
Ok(v) => ToolResult::json(&v),
Err(e) => ToolResult::error(format!("{name}: {e}")),
}
+2
View File
@@ -56,6 +56,8 @@ pub struct PreservedSheet {
pub rel_id: String,
/// How many worksheets come before it: its position among the model's sheets.
pub before: usize,
/// Its `sheetId` in the source file.
pub sheet_id: u32,
}
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
+5
View File
@@ -55,6 +55,10 @@ impl SheetViewMode {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sheet {
pub name: String,
/// The `sheetId` the file gave the sheet, written back on save so that what refers to the
/// sheet by its id keeps finding it; 0 for a sheet the file did not have. Not in JSON.
#[serde(skip)]
pub file_id: u32,
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")]
@@ -145,6 +149,7 @@ impl Sheet {
pub fn new(name: impl Into<String>) -> Sheet {
Sheet {
name: name.into(),
file_id: 0,
cells: CellStore::new(),
cell_pictures: BTreeMap::new(),
spill: BTreeMap::new(),
+10 -1
View File
@@ -193,6 +193,14 @@ pub struct Font {
/// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default.
#[serde(default)]
pub family: Option<u8>,
/// The font follows the theme's body font (`<scheme val="minor"/>`) while its face is the
/// theme's minor font. Fonts derived from it (bold, a colour, a size) keep the link.
#[serde(default = "default_minor")]
pub minor: bool,
}
fn default_minor() -> bool {
true
}
impl Eq for Font {}
@@ -200,7 +208,7 @@ impl Hash for Font {
fn hash<H: Hasher>(&self, h: &mut H) {
self.name.hash(h);
self.size.to_bits().hash(h);
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family).hash(h);
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family, self.minor).hash(h);
}
}
@@ -217,6 +225,7 @@ impl Default for Font {
vert: VertAlign::Baseline,
charset: None,
family: None,
minor: true,
}
}
}
+65 -3
View File
@@ -1,6 +1,7 @@
//! Workbooks.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use gridcraft_core::DateSystem;
use gridcraft_formula::Expr;
@@ -147,7 +148,8 @@ pub struct Workbook {
pub calc: CalcSettings,
#[serde(default, skip_serializing_if = "is_all")]
pub show_objects: ShowObjects,
pub active_sheet: usize,
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
pub active_sheet: ActiveSheet,
pub props: DocProps,
/// Workbook structure protection.
pub protected_structure: bool,
@@ -205,6 +207,57 @@ impl Default for Workbook {
}
}
/// The active sheet index of a [`Workbook`].
///
/// Every other part of a workbook value is immutable once shared (the engine keeps versions as
/// `Arc<Workbook>` and tells unsaved changes by pointer). The active sheet is the exception: it
/// is the view state of the window showing the workbook, and several windows on one workbook
/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it
/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook.
/// Copies (undo versions, a cloned workbook) take the value at the time of the copy.
#[derive(Default, Serialize, Deserialize)]
#[serde(from = "usize", into = "usize")]
pub struct ActiveSheet(AtomicUsize);
impl ActiveSheet {
pub fn get(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
pub fn set(&self, i: usize) {
self.0.store(i, Ordering::Relaxed);
}
}
impl From<usize> for ActiveSheet {
fn from(i: usize) -> ActiveSheet {
ActiveSheet(AtomicUsize::new(i))
}
}
impl From<ActiveSheet> for usize {
fn from(a: ActiveSheet) -> usize {
a.get()
}
}
impl Clone for ActiveSheet {
fn clone(&self) -> ActiveSheet {
ActiveSheet::from(self.get())
}
}
impl PartialEq for ActiveSheet {
fn eq(&self, other: &ActiveSheet) -> bool {
self.get() == other.get()
}
}
impl std::fmt::Debug for ActiveSheet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.get().fmt(f)
}
}
impl Workbook {
/// A new workbook with one sheet, "Sheet1".
pub fn new() -> Workbook {
@@ -216,7 +269,7 @@ impl Workbook {
date_system: DateSystem::D1900,
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
active_sheet: 0,
active_sheet: ActiveSheet::default(),
props: DocProps::default(),
protected_structure: false,
structure_password: None,
@@ -239,8 +292,17 @@ impl Workbook {
pub fn sheet_index(&self, name: &str) -> Option<usize> {
self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name))
}
/// The index of the sheet the workbook's window shows (the file's active tab).
pub fn active_sheet(&self) -> usize {
self.active_sheet.get()
}
/// Changes the active sheet in place, also through a shared `Arc<Workbook>`: it is view state,
/// so switching sheets or windows neither copies the workbook nor makes it a new version.
pub fn set_active_sheet(&self, i: usize) {
self.active_sheet.set(i);
}
pub fn active(&self) -> Option<&Sheet> {
self.sheet(self.active_sheet)
self.sheet(self.active_sheet())
}
/// A fresh sheet name `SheetN` not in use.
pub fn next_sheet_name(&self) -> String {
+10 -3
View File
@@ -9,10 +9,15 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
# craft-libs dependency uses (see the root Cargo.toml).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
gridcraft-engine = { workspace = true }
gridcraft-chart = { workspace = true }
egui = { workspace = true }
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest
# harness (and a host with no symbol font) renders them as tofu.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -31,12 +36,14 @@ epaint_default_fonts = { workspace = true }
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
# The desktop's preferred language for the first-run interface language.
sys-locale = { workspace = true }
craft-window = { 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"] }
web-sys = { version = "0.3", features = ["Window", "Navigator", "Performance"] }
[build-dependencies]
serde_json = { workspace = true }
+105
View File
@@ -12,6 +12,10 @@ use std::fmt::Write as _;
use serde_json::Value;
#[path = "src/craft_fonts_policy.rs"]
mod craft_fonts_policy;
/// Build script: reads the contributor JSON/TOML and writes generated `credits.rs`, then emits `craft_fonts.rs`; missing or malformed optional inputs warn, while required mode reports build errors.
fn main() {
// Read when the script runs: `env!` would keep the path from when the script was compiled,
// which goes stale if the checkout moves (and a missing file reruns the script every build).
@@ -27,6 +31,107 @@ fn main() {
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
println!("cargo::warning=writing {}: {e}", out.display());
}
emit_craft_fonts();
}
/// The optional craft-fonts build input (<https://github.com/storytold/craft-fonts>, recipe from
/// its `docs/integration.md`). With `CRAFT_FONTS_DIR=<absolute checkout>` the permitted fonts in
/// its `fonts/manifest.txt` are embedded as `CRAFT_FONTS` for `theme`'s fallback chains; unset,
/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1` (exactly
/// `1`; any other value leaves required mode off), which turns a missing checkout, a missing
/// required face or a malformed row into a build error.
///
/// GridCraft never copies font bytes into this repository and never reads a fixture from Cargo
/// configuration: the only input is the environment variable. Barred Adobe/Source/Noto CJK faces
/// stay in the shared repository for other consumers but are filtered here. A web build embeds
/// only the explicit `WEB_ALLOWED` faces (no CJK collection). Native builds additionally opt into
/// craft-fonts' `fonts/simplified-chinese-manifest.txt` (the OFL LXGW face DeckCraft uses): it is
/// optional for ordinary builds and required for `CRAFT_FONTS_REQUIRED=1` native builds.
fn emit_craft_fonts() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let required = craft_fonts_policy::required_mode(std::env::var("CRAFT_FONTS_REQUIRED").ok().as_deref());
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut src = String::new();
let _ = writeln!(src, "pub static CRAFT_FONTS_REQUIRED: bool = {required};");
src.push_str("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
match std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) {
Some(dir) => match craft_fonts(&dir, wasm, required) {
Ok(entries) => src.push_str(&entries),
Err(e) if required => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
},
None if required => {
println!("cargo::error=CRAFT_FONTS_REQUIRED=1 requires CRAFT_FONTS_DIR (an absolute craft-fonts checkout)");
}
None => {}
}
src.push_str("];\n");
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
}
}
/// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed.
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let rows = craft_fonts_policy::parse_manifest(&text)?;
craft_fonts_policy::check_declared_scripts(&rows)?;
if required {
craft_fonts_policy::check_required(&rows)?;
}
let chinese = if wasm { Vec::new() } else { chinese_font_rows(dir, required)? };
for row in rows.iter().chain(&chinese) {
if craft_fonts_policy::barred(&row.family) {
println!("cargo::warning=craft-fonts: not embedding {} {} (repository asset policy)", row.family, row.style);
}
}
let mut out = String::new();
for row in craft_fonts_policy::embeddable(&rows, wasm).into_iter().chain(craft_fonts_policy::embeddable(&chinese, wasm)) {
let path = dir.join(&row.file).canonicalize().map_err(|e| format!("{}: {e}", row.file))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = row.scripts.iter().map(|s| format!("{s:?}")).collect();
let family = &row.family;
let style = &row.style;
let _ = writeln!(
out,
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({path:?}) }},",
scripts.join(", "),
);
}
Ok(out)
}
/// The opt-in native Simplified Chinese rows. Older craft-fonts checkouts may lack the manifest, so
/// an ordinary build warns and carries on; a required acceptance build fails rather than passing
/// Chinese coverage only because the face was silently absent.
fn chinese_font_rows(dir: &std::path::Path, required: bool) -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
let manifest = dir.join("fonts/simplified-chinese-manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
let optional = |e: String| -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
if required {
Err(format!("{}: {e}", manifest.display()))
} else {
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", manifest.display());
Ok(Vec::new())
}
};
match std::fs::read_to_string(&manifest) {
Ok(text) => match craft_fonts_policy::parse_manifest(&text).and_then(|rows| {
craft_fonts_policy::check_declared_scripts(&rows)?;
craft_fonts_policy::check_chinese_required(&rows)?;
Ok(rows)
}) {
Ok(rows) => Ok(rows),
Err(e) => optional(e),
},
Err(e) => optional(e.to_string()),
}
}
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
+1 -1
View File
@@ -122,7 +122,7 @@ fn main() {
app.open_dialog(name, p);
}
// Page Layout view: edit the first page's centre header (or footer) section.
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s)));
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet(), s)));
if let (Some(part), Some((si, sh))) = (&hf_edit, active) {
let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 };
gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone);
+156 -49
View File
@@ -258,9 +258,10 @@ fn save(app: &mut SheetApp, out: &mut Outcome) {
// ---------------------------------------------------------------- navigation
/// Allocates and paints one Backstage navigation row: selected or hover fill, icon, label and button semantics. Returns the row's click response.
fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
let p = ui.painter();
if selected {
p.rect_filled(rect, 0.0, sel);
@@ -297,7 +298,7 @@ fn dim(ui: &mut Ui, text: &str) {
fn tile(ui: &mut Ui, label: &str, sample: Option<usize>) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let p = ui.painter();
let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0));
p.rect_filled(thumb, 4.0, t.grid_bg);
@@ -388,7 +389,7 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Ou
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
let (name, folder) = split_path(&path);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, 4.0, t.hover);
@@ -514,7 +515,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo
let t = Tokens::get(ui.ctx());
let width = ui.available_width().min(CONTENT_W);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, 4.0, t.hover);
@@ -529,7 +530,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo
fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let p = ui.painter();
p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon });
p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside);
@@ -589,6 +590,7 @@ pub fn protect_workbook(app: &mut SheetApp) {
}
}
/// Shows the workbook title, path, sheet count and structure protection, plus editable document properties. Committing a changed field after it loses focus runs `file.properties`.
fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
@@ -643,13 +645,13 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
ui.end_row();
for (i, (key, label)) in PROPERTIES.iter().enumerate() {
let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue };
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
let label = ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
let id = ui.make_persistent_id(("backstage_prop", *key));
// Show the workbook's value unless the field is being edited (undo, another window).
if !ui.ctx().memory(|m| m.has_focus(id)) {
draft.clone_from(saved);
}
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0));
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)).labelled_by(label.id);
if resp.lost_focus() && draft != saved {
let mut params = serde_json::Map::new();
params.insert((*key).to_string(), json!(draft.clone()));
@@ -660,6 +662,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
});
}
/// Draws the Save As type picker and action: xlsx opens the `saveAs` dialog, other types export, and the page closes when the save/export succeeds (or a web download handles it).
fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
let lang = app.ui.language;
dim(ui, lang.tr("Save the workbook under another name or in another folder."));
@@ -667,14 +670,19 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
let fmt = bs.save_format;
let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label);
ui.horizontal(|ui| {
ui.label(lang.tr("Save as type"));
egui::ComboBox::from_id_salt("backstage_save_type").width(280.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
for (ext, label) in SAVE_TYPES {
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
bs.save_format = ext;
let label = ui.label(lang.tr("Save as type"));
egui::ComboBox::from_id_salt("backstage_save_type")
.width(280.0)
.selected_text(lang.tr(current))
.show_ui(ui, |ui| {
for (ext, label) in SAVE_TYPES {
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
bs.save_format = ext;
}
}
}
});
})
.response
.labelled_by(label.id);
});
ui.add_space(14.0);
// Desktop: the system Save dialog. Web: the file downloads.
@@ -696,6 +704,7 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file."));
}
/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action.
fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
@@ -728,14 +737,19 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
}
ui.add_space(16.0);
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| {
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
bs.print_all = all;
bs.preview_page = 0;
egui::ComboBox::from_id_salt("backstage_print_scope")
.width(230.0)
.selected_text(lang.tr(scope))
.show_ui(ui, |ui| {
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
bs.print_all = all;
bs.preview_page = 0;
}
}
}
});
})
.response
.accessible_name(lang.tr("Print What"));
ui.add_space(8.0);
if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() {
bs.preview_page = 0;
@@ -935,35 +949,15 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
}
}
/// Excel's File › Options page: General (display theme, interface language and Show formula bar),
/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas
/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula
/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation
/// settings are saved with the workbook.
fn options(app: &mut SheetApp, ui: &mut Ui) {
let lang = app.ui.language;
section(ui, lang, "Options|General");
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
ui.label(lang.tr("Display theme"));
let mode = app.ui.theme_mode();
let current = match mode {
"system" => "System",
"dark" => "Dark",
_ => "Light",
};
egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
app.run_or_alert("view.theme", json!({"mode": value}));
}
}
});
ui.end_row();
ui.label(lang.tr("Interface language"));
egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| {
for l in i18n::Language::for_picker() {
if ui.selectable_label(l == lang, l.name()).clicked() {
app.run_or_alert("app.language.set", json!({"language": l.code()}));
}
}
});
ui.end_row();
});
general_options(app, ui, lang);
ui.add_space(6.0);
let mut formula_bar = app.session.prefs.formula_bar;
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
@@ -985,6 +979,53 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
ui.add_space(18.0);
section(ui, lang, "Formulas");
ui.label(lang.tr("Workbook calculation"));
calc_options(app, ui, lang, &calc);
ui.add_space(6.0);
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
}
/// The display theme and interface language controls of the General section.
fn general_options(app: &mut SheetApp, ui: &mut Ui, lang: Language) {
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
let label = ui.label(lang.tr("Display theme"));
let mode = app.ui.theme_mode();
let current = match mode {
"system" => "System",
"dark" => "Dark",
_ => "Light",
};
egui::ComboBox::from_id_salt("backstage_theme")
.width(160.0)
.selected_text(lang.tr(current))
.show_ui(ui, |ui| {
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
app.run_or_alert("view.theme", json!({"mode": value}));
}
}
})
.response
.labelled_by(label.id);
ui.end_row();
let label = ui.label(lang.tr("Interface language"));
egui::ComboBox::from_id_salt("backstage_language")
.width(160.0)
.selected_text(lang.name())
.show_ui(ui, |ui| {
for l in i18n::Language::for_picker() {
if ui.selectable_label(l == lang, l.name()).clicked() {
app.run_or_alert("app.language.set", json!({"language": l.code()}));
}
}
})
.response
.labelled_by(label.id);
ui.end_row();
});
}
/// The calculation mode, and iterative calculation, of the active workbook.
fn calc_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings) {
use gridcraft_engine::model::CalcMode;
for (mode, id, label) in [
(CalcMode::Automatic, "automatic", "Automatic"),
@@ -1000,8 +1041,74 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() {
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
}
ui.add_space(6.0);
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
// Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel.
iteration_options(app, ui, lang, calc, iterative);
// TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
// `precisionAsDisplayed` (PR #1366); the model already stores it.
}
/// Maximum Iterations and Maximum Change, live only while iterative calculation is on.
fn iteration_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings, iterative: bool) {
ui.add_enabled_ui(iterative, |ui| {
ui.horizontal(|ui| {
ui.add_space(24.0);
egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
let label = ui.label(lang.tr("Maximum Iterations:"));
let id = ui.make_persistent_id("backstage_max_iterations");
// Text left in the field when iteration is switched off (it loses focus) is dropped.
let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0));
if let Some(response) = ui.ctx().read_response(id) {
response.labelled_by(label.id);
}
if let Some(text) = committed.filter(|_| iterative) {
match parse_max_iterations(&text) {
Some(n) if n != calc.max_iterations => {
app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n}));
}
Some(_) => {}
None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."),
}
}
ui.end_row();
let label = ui.label(lang.tr("Maximum Change:"));
let id = ui.make_persistent_id("backstage_max_change");
let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0));
if let Some(response) = ui.ctx().read_response(id) {
response.labelled_by(label.id);
}
if let Some(text) = committed.filter(|_| iterative) {
match parse_max_change(&text, lang.formula_locale()) {
Some(d) if d != calc.max_change => {
app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d}));
}
Some(_) => {}
None => refuse(app, ui, "Maximum Change must be a number of 0 or more."),
}
}
ui.end_row();
});
});
});
}
/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that
/// committed the field is consumed so it doesn't also dismiss the message box in this frame.
fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) {
ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into()));
}
/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others).
fn parse_max_iterations(text: &str) -> Option<u32> {
let n = text.trim().parse::<u32>().ok()?;
(1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n)
}
/// Maximum Change as typed: a finite number of 0 or more, or `None`. The interface's decimal
/// separator is accepted as well as a dot.
fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale) -> Option<f64> {
let d = text.trim().replace(locale.decimal_separator(), ".").parse::<f64>().ok()?;
(d.is_finite() && d >= 0.0).then_some(d)
}
/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web".
+2 -2
View File
@@ -10,7 +10,7 @@ use crate::theme::{self, Tokens};
/// 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));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::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 = [
@@ -55,7 +55,7 @@ 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"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::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);
+2 -1
View File
@@ -116,13 +116,14 @@ fn fitted_rect(cell: Rect, size: [usize; 2]) -> Option<Rect> {
Some(Rect::from_center_size(cell.center(), source * scale))
}
/// Draws a cached cell picture fitted inside the cell and clipped to it, or paints the picture fallback icon when no texture is available.
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 cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::unique("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 {
+46 -38
View File
@@ -78,6 +78,32 @@ pub fn highlight(text: &str, lang: Lang) -> Vec<(Range<usize>, Token)> {
}
}
/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`.
fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize {
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
let mut j = if triple { i + 3 } else { i + 1 };
loop {
match chars.get(j) {
None => break,
Some((_, '\\')) => j += 2,
Some((_, '\n')) if !triple => break,
Some((_, d)) if *d == c => {
if !triple {
j += 1;
break;
}
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
j += 3;
break;
}
j += 1;
}
Some(_) => j += 1,
}
}
j.min(chars.len())
}
fn python(text: &str) -> Vec<(Range<usize>, Token)> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
@@ -92,28 +118,7 @@ fn python(text: &str) -> Vec<(Range<usize>, Token)> {
out.push((start..end_of(j), Token::Comment));
i = j;
} else if c == '"' || c == '\'' {
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
let mut j = if triple { i + 3 } else { i + 1 };
loop {
match chars.get(j) {
None => break,
Some((_, '\\')) => j += 2,
Some((_, '\n')) if !triple => break,
Some((_, d)) if *d == c => {
if !triple {
j += 1;
break;
}
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
j += 3;
break;
}
j += 1;
}
Some(_) => j += 1,
}
}
let j = j.min(chars.len());
let j = python_string_end(&chars, i, c);
out.push((start..end_of(j), Token::String));
i = j;
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) {
@@ -551,7 +556,10 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
ui.scroll_to_rect(caret, None);
}
let mut r = out.response.response;
// The field has no visible label; name it for screen readers and the strict
// kittest accessibility check. The editor is a generic code field, so the name is
// not part of the UI's translated strings.
let mut r = out.response.response.accessible_name("Code Editor");
if edited.is_some() {
r.mark_changed();
}
@@ -667,17 +675,15 @@ mod tests {
}
#[test]
/// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret.
fn shift_tab_in_the_editor_dedents_rather_than_indents() {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|ui, text: &mut String| {
if ui.ctx().cumulative_pass_nr() == 0 {
let mut fonts = egui::FontDefinitions::default();
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
ui.ctx().set_fonts(fonts);
ui.ctx().set_fonts(theme::pinned_font_definitions());
return;
}
let id = egui::Id::new("code");
let id = egui::Id::unique("code");
ui.memory_mut(|m| m.request_focus(id));
code_editor(ui, id, text, Lang::Python, 5);
},
@@ -694,14 +700,12 @@ mod tests {
assert_eq!(h.state(), " x ");
}
/// Gives the context the editor's monospace family (the test has no craft fonts).
/// Gives the context the deterministic font set, including the editor's monospace family.
fn install_mono(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
ctx.set_fonts(fonts);
ctx.set_fonts(theme::pinned_font_definitions());
}
/// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check.
fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|ui, text: &mut String| {
@@ -709,13 +713,14 @@ mod tests {
install_mono(ui.ctx());
return;
}
let id = egui::Id::new("code");
let id = egui::Id::unique("code");
ui.memory_mut(|m| m.request_focus(id));
code_editor(ui, id, text, Lang::Python, 5);
},
text.to_string(),
);
h.run_steps(2);
h.check_accessibility();
h
}
@@ -869,9 +874,10 @@ mod tests {
}
#[test]
/// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared.
fn held_back_events_land_in_the_next_pass_only() {
let mut h = editor_harness("");
let id = egui::Id::new("code");
let id = egui::Id::unique("code");
let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] };
// What the pass just completed held back comes in with the next one.
let last = h.ctx.cumulative_pass_nr() - 1;
@@ -886,6 +892,7 @@ mod tests {
}
#[test]
/// Held keyboard input does not land in the next focused text field once the code editor has lost focus.
fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() {
// Typed into the editor, they must not land in whatever has the focus next.
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
@@ -894,10 +901,10 @@ mod tests {
install_mono(ui.ctx());
return;
}
let (code, other) = (egui::Id::new("code"), egui::Id::new("other"));
let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other"));
ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other }));
code_editor(ui, code, &mut s.0, Lang::Python, 5);
ui.add(TextEdit::singleline(&mut s.1).id(other));
ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field");
},
(String::new(), String::new(), true),
);
@@ -911,6 +918,7 @@ mod tests {
}
#[test]
/// A discard-triggered repeat pass receives held keys with the selection made by the first pass.
fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() {
// When a widget asks egui to discard and repeat the pass, the repeat pass gets the
// held-back keys, with the selection the field made in the first pass.
@@ -920,7 +928,7 @@ mod tests {
install_mono(ui.ctx());
return;
}
let id = egui::Id::new("code");
let id = egui::Id::unique("code");
ui.memory_mut(|m| m.request_focus(id));
code_editor(ui, id, &mut s.0, Lang::Python, 5);
if std::mem::take(&mut s.1) {
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
//! craft-fonts build policy shared by `build.rs` and the UI crate.
//!
//! GridCraft stays a clean-room application: the shared repository may keep Adobe, Source Han and
//! Noto CJK faces for other consumers, but GridCraft never embeds them. The permitted faces are
//! matched by exact family and style, and their manifest script tags are asserted instead of
//! trusting the free-form parser in `docs/integration.md`.
//!
//! The file is compiled twice: by `build.rs` for the full parser/comparison/embedding path (via
//! `#[path]`), and by this crate, where `theme` only needs the barred-family filter and the web
//! allowlist. The rest of the policy is exercised by this file's own tests, so it is not dead
//! code to remove.
#![allow(dead_code)]
/// Families GridCraft must never embed: the repository asset policy excludes Adobe/Source faces
/// and the Noto CJK collection. There is deliberately no broad `Noto Sans` prefix: the permitted
/// `Noto Sans Symbols` and `Noto Sans Symbols 2` faces start with it.
pub const BARRED_PREFIXES: &[&str] = &["Adobe", "Source Han", "Source Sans", "Source Serif", "Noto Sans CJK", "Noto Serif CJK"];
/// The faces GridCraft's production font set relies on, with their exact manifest script tags.
/// Every release/acceptance build that sets `CRAFT_FONTS_REQUIRED=1` must contain all of them.
pub const REQUIRED_FACES: &[(&str, &str, &[&str])] = &[
("BIZ UDPGothic", "Regular", &["Jpan", "Latn"]),
("BIZ UDPGothic", "Bold", &["Jpan", "Latn"]),
("NanumGothic", "Regular", &["Hang", "Kore"]),
("Noto Sans Symbols", "Regular", &["Zsym"]),
("Noto Sans Symbols 2", "Regular", &["Zsym"]),
];
/// The native-only Simplified Chinese face from craft-fonts' separate
/// `fonts/simplified-chinese-manifest.txt` (the same opt-in DeckCraft uses). It is OFL and not
/// barred by the asset policy, but it never enters a web bundle; older native checkouts may omit
/// the manifest, so only `CRAFT_FONTS_REQUIRED=1` builds demand it.
pub const CHINESE_FACES: &[(&str, &str, &[&str])] = &[("LXGW WenKai Lite", "Regular", &["Hani", "Hans"])];
/// The explicit web allowlist. Browser builds embed only these faces; the Noto CJK collection and
/// the document-only faces stay off the wasm bundle. `Noto Sans Symbols` supplies `⌃`, and
/// `Noto Sans Symbols 2` supplies `✕`, `◀` and `✓`; `📋` still comes from egui's bundled emoji.
pub const WEB_ALLOWED: &[(&str, &str)] = &[
("BIZ UDPGothic", "Regular"),
("BIZ UDPGothic", "Bold"),
("NanumGothic", "Regular"),
("Noto Sans Symbols", "Regular"),
("Noto Sans Symbols 2", "Regular"),
];
/// One validated `fonts/manifest.txt` row. Only the fields GridCraft consumes are kept.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestRow {
pub family: String,
pub style: String,
pub file: String,
pub scripts: Vec<String>,
pub sha256: String,
}
/// Whether the `CRAFT_FONTS_REQUIRED` value enables required mode. Only exactly `1` does:
/// `0`, an empty value and other words are not accepted as an implicit yes.
pub fn required_mode(value: Option<&str>) -> bool {
value == Some("1")
}
/// Whether `family` is barred by GridCraft's asset policy.
pub fn barred(family: &str) -> bool {
BARRED_PREFIXES.iter().any(|prefix| family.starts_with(prefix))
}
/// Whether a web build may embed this exact family and style.
pub fn web_allowed(family: &str, style: &str) -> bool {
WEB_ALLOWED.iter().any(|(f, s)| *f == family && *s == style)
}
/// The exact script tags a required face must declare.
pub fn required_scripts(family: &str, style: &str) -> Option<&'static [&'static str]> {
REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).find(|(f, s, _)| *f == family && *s == style).map(|(_, _, scripts)| *scripts)
}
/// Parse the craft-fonts eight-field manifest. Comments and blank lines are skipped; every other
/// line must be complete and contain a valid SHA-256. A malformed row is an error, never a
/// silently ignored font.
pub fn parse_manifest(text: &str) -> Result<Vec<ManifestRow>, String> {
let mut rows = Vec::new();
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, licence, licence_file, sha256, source] = fields.as_slice() else {
return Err(format!("manifest line {}: expected 8 fields separated by \" | \": {line}", number + 1));
};
if [family, style, file, scripts, licence, licence_file, sha256, source].iter().any(|f| f.is_empty()) {
return Err(format!("manifest line {}: an empty field: {line}", number + 1));
}
if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("manifest line {}: sha256 must be 64 hex digits: {sha256}", number + 1));
}
let scripts: Vec<String> = scripts.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
if scripts.is_empty() || scripts.iter().any(|tag| tag.len() != 4 || !tag.chars().all(|c| c.is_ascii_alphabetic())) {
return Err(format!("manifest line {}: script tags must be ISO 15924 four-letter tags: {line}", number + 1));
}
rows.push(ManifestRow {
family: (*family).to_string(),
style: (*style).to_string(),
file: (*file).to_string(),
scripts,
sha256: (*sha256).to_string(),
});
}
Ok(rows)
}
/// Check the script tags of every face GridCraft knows. The craft-fonts parser accepts arbitrary
/// strings, so a face relabelled without the scripts GridCraft relies on is rejected here.
pub fn check_declared_scripts(rows: &[ManifestRow]) -> Result<(), String> {
for row in rows {
if let Some(expected) = required_scripts(&row.family, &row.style) {
if row.scripts.iter().map(String::as_str).ne(expected.iter().copied()) {
return Err(format!(
"{} {} must declare scripts {} but the manifest says {}",
row.family,
row.style,
expected.join(","),
row.scripts.join(",")
));
}
} else if REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).any(|(family, _, _)| *family == row.family) {
return Err(format!("{} has an unexpected style {:?}; GridCraft only permits the exact required faces", row.family, row.style));
}
}
Ok(())
}
/// Every required face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` builds.
pub fn check_required(rows: &[ManifestRow]) -> Result<(), String> {
let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style);
let missing: Vec<String> = REQUIRED_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect();
if missing.is_empty() { Ok(()) } else { Err(format!("the craft-fonts manifest is missing required faces: {}", missing.join(", "))) }
}
/// Every native-only Chinese face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` native
/// builds; ordinary builds may use an older craft-fonts checkout without the opt-in manifest.
pub fn check_chinese_required(rows: &[ManifestRow]) -> Result<(), String> {
let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style);
let missing: Vec<String> = CHINESE_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect();
if missing.is_empty() {
Ok(())
} else {
Err(format!("the simplified-Chinese craft-fonts manifest is missing required faces: {}", missing.join(", ")))
}
}
/// The rows this build should embed, in manifest order. Barred families are always excluded; a
/// web build additionally keeps only [`WEB_ALLOWED`].
pub fn embeddable(rows: &[ManifestRow], wasm: bool) -> Vec<&ManifestRow> {
rows.iter().filter(|row| !barred(&row.family) && (!wasm || web_allowed(&row.family, &row.style))).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD: &str = "NanumGothic | Regular | fonts/nanum-gothic/NanumGothic-Regular.ttf | Hang,Kore | OFL-1.1 | fonts/nanum-gothic/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/nanum";
#[test]
/// Asserts the exact web allowlist and that every entry is unbarred, required and not a CJK collection face.
fn the_web_allowlist_is_exact_and_never_barred() {
assert_eq!(
WEB_ALLOWED,
&[
("BIZ UDPGothic", "Regular"),
("BIZ UDPGothic", "Bold"),
("NanumGothic", "Regular"),
("Noto Sans Symbols", "Regular"),
("Noto Sans Symbols 2", "Regular"),
]
);
for (family, _) in WEB_ALLOWED {
assert!(!barred(family), "{family} must not be barred");
assert!(REQUIRED_FACES.iter().any(|(f, _, _)| f == family), "{family} must be a required face");
}
assert!(!WEB_ALLOWED.iter().any(|(family, _)| family.contains("CJK")), "the web allowlist must contain no CJK collection face");
}
#[test]
/// Adobe/Source and Noto CJK families are barred, while the permitted Noto Symbols, BIZ and Nanum faces are not.
fn the_barred_prefixes_keep_the_permitted_noto_symbol_faces() {
for family in ["Adobe Source Sans", "Source Han Sans", "Source Sans 3", "Source Serif 4", "Noto Sans CJK SC", "Noto Serif CJK JP"] {
assert!(barred(family), "{family} must be barred");
}
for family in ["Noto Sans Symbols", "Noto Sans Symbols 2", "BIZ UDPGothic", "NanumGothic"] {
assert!(!barred(family), "{family} must be permitted");
}
}
#[test]
/// Only the exact string `"1"` enables required mode; absent and all other values leave it off.
fn only_the_value_one_enables_required_mode() {
assert!(!required_mode(None));
for value in ["", "0", "00", "01", "false", "true", "yes"] {
assert!(!required_mode(Some(value)), "{value:?} must not enable required mode");
}
assert!(required_mode(Some("1")));
}
#[test]
/// A complete eight-field row yields the expected family, style and script list.
fn a_complete_manifest_row_parses() {
let rows = parse_manifest(GOOD).unwrap_or_default();
assert_eq!(rows.len(), 1);
assert_eq!(rows.first().map(|r| (r.family.as_str(), r.style.as_str())), Some(("NanumGothic", "Regular")));
assert_eq!(rows.first().map(|r| r.scripts.clone()), Some(vec!["Hang".to_string(), "Kore".to_string()]));
}
#[test]
/// Missing fields, an empty file field, a bad hash and a non-ISO script tag are all rejected.
fn malformed_rows_are_errors() {
for bad in [
"NanumGothic | Regular | fonts/x.ttf | Hang,Kore",
"NanumGothic | Regular | | Hang,Kore | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid",
"NanumGothic | Regular | fonts/x.ttf | Hang,Kore | OFL-1.1 | fonts/OFL.txt | notahash | https://example.invalid",
"NanumGothic | Regular | fonts/x.ttf | Korean | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid",
] {
assert!(parse_manifest(bad).is_err(), "must reject {bad:?}");
}
}
#[test]
/// One row cannot satisfy every required face; script order and exact tags are checked, and a required family in an unlisted style is rejected.
fn required_faces_and_script_tags_are_checked() {
let rows = parse_manifest(GOOD).unwrap_or_default();
assert!(check_required(&rows).is_err(), "one row cannot satisfy every required face");
assert!(check_declared_scripts(&rows).is_ok());
let relabelled = parse_manifest(&GOOD.replace("Hang,Kore", "Kore,Hang")).unwrap_or_default();
assert!(check_declared_scripts(&relabelled).is_err(), "script order and exact tags are asserted");
let unexpected_style = parse_manifest(&GOOD.replace("Regular", "Bold")).unwrap_or_default();
assert!(check_declared_scripts(&unexpected_style).is_err(), "a required family in an unlisted style is rejected");
}
#[test]
/// LXGW is permitted but native-only; its script order and style are checked, and a required native build must include it.
fn the_native_chinese_face_is_permitted_and_checked() {
let chinese = "LXGW WenKai Lite | Regular | fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.ttf | Hani,Hans | OFL-1.1 | fonts/lxgw-wenkai-lite/OFL.txt | f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea | https://example.invalid/lxgw";
let rows = parse_manifest(chinese).unwrap_or_default();
assert!(check_declared_scripts(&rows).is_ok());
assert!(check_chinese_required(&rows).is_ok());
assert!(!barred("LXGW WenKai Lite"), "the OFL Chinese face is permitted");
assert!(!web_allowed("LXGW WenKai Lite", "Regular"), "the Chinese face is native-only");
let relabelled = parse_manifest(&chinese.replace("Hani,Hans", "Hans,Hani")).unwrap_or_default();
assert!(check_declared_scripts(&relabelled).is_err(), "the Chinese script tags are asserted");
let unexpected_style = parse_manifest(&chinese.replace("Regular", "Bold")).unwrap_or_default();
assert!(check_declared_scripts(&unexpected_style).is_err(), "an unlisted Chinese style is rejected");
assert!(check_chinese_required(&[]).is_err(), "a required native build needs the Chinese face");
}
#[test]
/// Native embedding excludes the barred CJK face; web embedding keeps only allowlisted faces.
fn web_embedding_keeps_only_the_allowlist() {
let manifest = format!(
"{GOOD}\nNoto Sans CJK SC | Regular | fonts/cjk.otf | Hans,Latn | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/cjk"
);
let rows = parse_manifest(&manifest).unwrap_or_default();
let native: Vec<&str> = embeddable(&rows, false).iter().map(|r| r.family.as_str()).collect();
let web: Vec<&str> = embeddable(&rows, true).iter().map(|r| r.family.as_str()).collect();
assert_eq!(native, ["NanumGothic"], "the barred CJK face is never embedded natively");
assert_eq!(web, ["NanumGothic"], "the web build keeps only allowlisted faces");
}
}
+1 -1
View File
@@ -212,7 +212,7 @@ impl Default for View {
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
+122 -82
View File
@@ -113,7 +113,7 @@ impl Dialog {
pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) {
let Some(pick) = self.pick.as_mut() else { return };
let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string();
let mut ed = crate::editor::EditState::new(0, cell, text, true, false);
let mut ed = crate::editor::EditState::new(0, 0, cell, text, true, false);
ed.caret = pick.caret.min(ed.text.chars().count());
ed.point = pick.point;
if ed.text.trim().is_empty() {
@@ -199,6 +199,20 @@ impl Dialog {
}],
json!({"shift": "down"}),
),
"arrangeAll" => Dialog::form(
"arrangeAll",
"Arrange Windows",
"view.arrangeAll",
vec![
Choice {
key: "arrange",
label: "Arrange",
options: vec![("tiled", "Tiled"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("cascade", "Cascade")],
},
Check { key: "activeWorkbook", label: "Windows of active workbook" },
],
json!({"arrange": "tiled", "activeWorkbook": false}),
),
"insertPaste" => Dialog::form(
"insertPaste",
"Insert Paste",
@@ -221,8 +235,8 @@ impl Dialog {
"rowHeight",
"Row Height",
"home.rowHeight",
vec![Number { key: "height", label: "Row height (pixels):" }],
json!({"height": app.session.active().and_then(|d| d.wb.active().map(|s| s.row_height(d.selection.active.row))).unwrap_or(20.0)}),
vec![Number { key: "points", label: "Row height (points):" }],
json!({"points": app.session.active().and_then(|d| d.wb.active().map(|s| s.row_height(d.selection.active.row) as f64 * 0.75)).unwrap_or(15.0)}),
),
"columnWidth" => Dialog::form(
"columnWidth",
@@ -605,7 +619,7 @@ impl Dialog {
}
"solver" => {
// Starts from the sheet's saved model, else the active cell as objective.
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet));
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet()));
let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"});
if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) {
for (k, x) in m {
@@ -886,7 +900,7 @@ impl Dialog {
// Chart Design › Select Data: data range, series to keep, category axis labels.
let id = p("chart").as_u64();
let found = app.session.active().and_then(|doc| {
let si = doc.wb.active_sheet;
let si = doc.wb.active_sheet();
let sh = doc.wb.sheet(si)?;
let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;
let data = gridcraft_chart::resolve(&doc.wb, si, ch);
@@ -1251,8 +1265,10 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
v
}
/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack.
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let home = app.ui_window();
if d.name == "pasteSpecial" {
paste_special_shortcuts(&mut d, ctx);
}
@@ -1271,7 +1287,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
_ => 380.0,
};
let window = egui::Window::new(app.ui.language.tr(&d.title))
.id(egui::Id::new("dialog").with(&d.name))
.id(egui::Id::unique("dialog").with(&d.name))
.collapsible(false)
.resizable(false)
.default_width(width)
@@ -1438,29 +1454,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
"saveChanges" => {
ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook")));
// Asked while quitting: once answered, the next unsaved workbook is asked about.
let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true);
ui.horizontal(|ui| {
if ui.button("Don't Save").clicked() {
app.run_or_alert("file.close", json!({"force": true}));
if quitting {
app.continue_quit(ui.ctx());
for (label, answer) in
[("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)]
{
if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) {
open = false;
}
open = false;
}
if ui.button("Cancel").clicked() {
open = false;
}
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) {
app.run_or_alert("file.save", json!({}));
if app.session.active().is_some_and(|x| !x.is_dirty()) {
app.run_or_alert("file.close", json!({"force": true}));
if quitting {
app.continue_quit(ui.ctx());
}
}
open = false;
}
});
}
@@ -1545,6 +1546,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
} else if confirm {
open = false;
}
if app.ui_window() != home {
// A command run from the dialog switched windows: whatever it opened shows in the new
// window, and this dialog (or the one it returns to) stays in its own.
let keep = if open { Some(d) } else { d.back.take().map(|b| *b) };
if let Some(keep) = keep {
app.return_dialog(home, keep);
}
return;
}
if let Some(child) = app.dialog.as_mut() {
// The body opened another dialog (Name Manager › New…). It takes over; this one waits
// behind it and comes back when it closes, as Excel returns to the Name Manager. Go To
@@ -1775,7 +1785,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
match f {
Field::Text { key, label } => {
ui.vertical(|ui| {
ui.label(lang.tr(label));
let label = ui.label(lang.tr(label));
let mut s = d
.values
.get(key)
@@ -1783,9 +1793,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
.unwrap_or_default();
let multiline = key == "text";
let r = if multiline {
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0))
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id)
} else {
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0))
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id)
};
take_first_focus(d, &r);
if r.changed() {
@@ -1799,9 +1809,10 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
Field::Password { key, label } => {
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
ui.vertical(|ui| {
ui.label(lang.tr(label));
let label = ui.label(lang.tr(label));
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string();
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() {
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed()
{
d.values.insert(key.into(), json!(s));
}
ui.checkbox(&mut d.show_password, lang.tr("Show password"));
@@ -1809,11 +1820,11 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
}
Field::Number { key, label } => {
ui.horizontal(|ui| {
ui.label(lang.tr(label));
let label = ui.label(lang.tr(label));
let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0);
// Typed values count straight away: otherwise Enter or OK in the same frame
// confirms the old value, because a number field only commits when it loses focus.
let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true));
let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id);
take_first_focus(d, &r);
if r.changed() {
d.values.insert(key.into(), json!(n));
@@ -1822,17 +1833,22 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
}
Field::Choice { key, label, options } => {
ui.vertical(|ui| {
ui.label(egui::RichText::new(lang.tr(label)).strong());
let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong());
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
if d.name == "dataValidation" {
let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or("");
egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| {
for (val, text) in options {
if ui.selectable_label(cur == val, lang.tr(text)).clicked() {
d.set_value(key, json!(val));
egui::ComboBox::from_id_salt(("dataValidation", key))
.selected_text(selected)
.width(300.0)
.show_ui(ui, |ui| {
for (val, text) in options {
if ui.selectable_label(cur == val, lang.tr(text)).clicked() {
d.set_value(key, json!(val));
}
}
}
});
})
.response
.labelled_by(field_label.id);
} else {
for (val, text) in options {
if ui.radio(cur == val, lang.tr(text)).clicked() {
@@ -1924,6 +1940,7 @@ fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
}
/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates.
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
// `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn
// labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a
@@ -1963,8 +1980,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default();
cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0)));
cols[1].add_space(8.0);
cols[1].label(lang.tr("Type (custom format code):"));
cols[1].text_edit_singleline(&mut code);
let label = cols[1].label(lang.tr("Type (custom format code):"));
cols[1].text_edit_singleline(&mut code).labelled_by(label.id);
for c in [
"0",
"0.00",
@@ -2100,8 +2117,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
] {
let label = lang.tr(label);
let response = ui.selectable_label(slot.style == s, label);
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
let selected = slot.style == s;
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}")));
if response.clicked() {
slot.style = s;
}
@@ -2193,7 +2210,7 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
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 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}("),
@@ -2218,6 +2235,7 @@ fn function_category_label(id: &str) -> &str {
}
}
/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters.
fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
for tab in ["Find", "Replace"] {
@@ -2229,8 +2247,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string();
let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.label("Find what:");
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0));
let label = ui.label("Find what:");
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id);
// Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works.
if !d.values.contains_key("focused") {
r.request_focus();
@@ -2239,8 +2257,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
if d.tab == "Replace" {
ui.horizontal(|ui| {
ui.label("Replace with:");
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0));
let label = ui.label("Replace with:");
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id);
});
}
let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false);
@@ -2356,14 +2374,16 @@ fn go_to_entries(app: &SheetApp) -> Vec<String> {
app.session
.active()
.map(|doc| {
let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet);
let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet());
v.extend(doc.go_to_recent.iter().cloned());
v
})
.unwrap_or_default()
}
/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`.
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
let lang = app.ui.language;
let entries: Vec<String> =
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
@@ -2382,8 +2402,8 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
});
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.label("Reference:");
ui.text_edit_singleline(&mut r).request_focus();
let label = ui.label(lang.tr("Reference:"));
ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus();
});
d.values.insert("reference".into(), json!(r.clone()));
if ui.button("Special…").clicked() {
@@ -2401,7 +2421,9 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
}
}
/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation.
fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
let lang = app.ui.language;
let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true);
ui.checkbox(&mut header, "My list has headers");
d.values.insert("header".into(), json!(header));
@@ -2432,16 +2454,20 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
let mut remove = None;
for (i, lv) in levels.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.label(if i == 0 { "Sort by" } else { "Then by" });
let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" }));
let mut col = lv["column"].as_str().unwrap_or("A").to_string();
if columns.is_empty() {
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id);
} else {
egui::ComboBox::from_id_salt(("sort_col", i)).selected_text(col.clone()).show_ui(ui, |ui| {
for (letter, label) in &columns {
ui.selectable_value(&mut col, letter.clone(), label.as_str());
}
});
egui::ComboBox::from_id_salt(("sort_col", i))
.selected_text(col.clone())
.show_ui(ui, |ui| {
for (letter, label) in &columns {
ui.selectable_value(&mut col, letter.clone(), label.as_str());
}
})
.response
.labelled_by(label.id);
}
lv["column"] = json!(col.to_ascii_uppercase());
let mut by = lv["by"].as_str().unwrap_or("values").to_string();
@@ -2455,13 +2481,16 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
ui.selectable_value(&mut by, "values".into(), "Cell Values");
ui.selectable_value(&mut by, "cellColor".into(), "Cell Color");
ui.selectable_value(&mut by, "fontColor".into(), "Font Color");
});
})
.response
.accessible_name(lang.tr("Sort On"));
lv["by"] = json!(by);
let mut desc = lv["order"].as_str() == Some("desc");
ui.radio_value(&mut desc, false, "A to Z");
ui.radio_value(&mut desc, true, "Z to A");
lv["order"] = json!(if desc { "desc" } else { "asc" });
if ui.small_button("✕").clicked() {
if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked()
{
remove = Some(i);
}
});
@@ -2746,12 +2775,13 @@ fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, con
}
}
/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed.
fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
ui.label(label);
let label = ui.label(label);
let mut v = text(d, key);
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() {
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() {
d.values.insert(key.into(), json!(v));
}
};
@@ -3094,16 +3124,17 @@ fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confi
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
/// command; the result opens Solver Results.
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let lang = crate::i18n::current(ui.ctx());
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| {
let mut t = text(d, k);
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() {
d.values.insert(k.into(), json!(t));
}
};
ui.horizontal(|ui| {
ui.label("Set Objective:");
edit(ui, d, "objective", 200.0);
edit(ui, d, "objective", 200.0, "Set Objective:");
});
let goal = text(d, "goal");
ui.horizontal(|ui| {
@@ -3113,10 +3144,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
d.values.insert("goal".into(), json!(g));
}
}
edit(ui, d, "value", 80.0);
edit(ui, d, "value", 80.0, "Value Of:");
});
ui.label("By Changing Variable Cells:");
edit(ui, d, "variables", 420.0);
edit(ui, d, "variables", 420.0, "By Changing Variable Cells:");
ui.label("Subject to the Constraints:");
let list: Vec<String> = d
.values
@@ -3135,17 +3166,22 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
});
});
ui.horizontal(|ui| {
edit(ui, d, "newLhs", 110.0);
edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference");
let op = text(d, "newOp");
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
for o in ["<=", "=", ">=", "int", "bin"] {
if ui.selectable_label(op == o, o).clicked() {
d.values.insert("newOp".into(), json!(o));
egui::ComboBox::from_id_salt("solverOp")
.selected_text(op.clone())
.width(60.0)
.show_ui(ui, |ui| {
for o in ["<=", "=", ">=", "int", "bin"] {
if ui.selectable_label(op == o, o).clicked() {
d.values.insert("newOp".into(), json!(o));
}
}
}
});
})
.response
.accessible_name(lang.tr("Constraint Operator"));
if !matches!(op.as_str(), "int" | "bin") {
edit(ui, d, "newRhs", 110.0);
edit(ui, d, "newRhs", 110.0, "Constraint Value");
}
if ui.button("Add").clicked() {
let lhs = text(d, "newLhs");
@@ -3178,13 +3214,17 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
"evolutionary" => "Evolutionary",
_ => "GRG Nonlinear",
};
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
for m in ["grg", "simplex", "evolutionary"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
egui::ComboBox::from_id_salt("solverMethod")
.selected_text(label(&method))
.show_ui(ui, |ui| {
for m in ["grg", "simplex", "evolutionary"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
}
}
}
});
})
.response
.accessible_name(lang.tr("Select a Solving Method:"));
});
ui.label(
egui::RichText::new(match method.as_str() {
+297
View File
@@ -0,0 +1,297 @@
//! Desktop: each workbook window is a native window of its own.
//!
//! The engine owns the windows ([`Session::windows`]); [`DocWindows`] mirrors the visible ones in
//! a [`WindowList`] and draws each through [`Viewports`]: the root viewport shows one window and
//! every other window is an immediate viewport. Before a window draws, it becomes the engine's
//! window and its interface state is swapped in ([`SheetApp::show_window`]); after the frame the
//! focused window is active again, so menu commands, scripts and shortcuts outside a window's own
//! input go to the window the user worked in last.
//!
//! Close and quit:
//! - A window's close button closes that window (`window.close`): one of several windows on a
//! workbook closes without asking; a workbook's last window asks Save / Don't Save / Cancel when
//! it has unsaved changes.
//! - Quit ([`SheetApp::request_quit`]) asks about each unsaved workbook in its window; Cancel stops.
//! - When the root's window closes while others are open, another window moves into the root.
//! - With no window left, the app quits, or (macOS) keeps running with its root hidden and the
//! menu bar showing until a workbook is created or opened.
//!
//! [`Session::windows`]: gridcraft_engine::Session::windows
use craft_window::{CASCADE_STEP, Frame, Rect, Viewports, WindowEvent, WindowId, WindowList, cascade};
use serde_json::json;
use crate::SheetApp;
/// The native windows (see the module docs).
pub struct DocWindows {
/// Engine window id per native window.
list: WindowList<u64>,
viewports: Viewports,
/// The builder new windows start from: the root window's size, icon and frame settings.
base: egui::ViewportBuilder,
/// Quit when the last window closes (Windows, Linux); macOS keeps running without a window.
quit_with_last_window: bool,
/// The root viewport is hidden because no window is open.
root_hidden: bool,
}
impl DocWindows {
/// Takes over `app`'s windows. `base` is the root window's builder, used for new windows.
pub fn new(app: &mut SheetApp, base: egui::ViewportBuilder, quit_with_last_window: bool) -> DocWindows {
app.native_windows = true;
// The root window keeps the desktop's frame, so every window does.
DocWindows { list: WindowList::new(), viewports: Viewports::new(Frame::Desktop), base, quit_with_last_window, root_hidden: false }
}
/// The platform default for the last window closing: macOS keeps running, others quit.
pub fn platform_quits_with_last_window() -> bool {
!cfg!(target_os = "macos")
}
/// The native window showing engine window `window`.
fn find(&self, window: u64) -> Option<WindowId> {
self.list.find(|w| *w == window)
}
/// The engine window drawn in the root viewport.
pub fn root_window(&self) -> Option<u64> {
self.viewports.root().and_then(|w| self.list.get(w).copied())
}
/// The engine windows that have a native window, in opening order.
pub fn shown_windows(&self) -> Vec<u64> {
self.list.iter().map(|(_, w)| *w).collect()
}
/// The egui viewport showing engine window `window`.
pub fn viewport_of(&self, window: u64) -> Option<egui::ViewportId> {
self.find(window).map(|id| self.viewports.viewport_id(id))
}
/// The egui viewport of the focused window, where menu commands that act on input (copy,
/// paste, undo in a text box) go.
pub fn focused_viewport(&self) -> egui::ViewportId {
self.list.active().map(|w| self.viewports.viewport_id(w)).unwrap_or(egui::ViewportId::ROOT)
}
/// Call each frame after [`SheetApp::logic`], also while the root is hidden (eframe runs only
/// the logic then): shows windows created or opened by menu commands, files and scripts.
pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
self.sync(app, ctx);
if let Some((arrange, windows)) = app.arrange_windows.take() {
self.arrange(ctx, &arrange, &windows);
}
}
/// View › Arrange All: moves and sizes the native windows of `windows` (engine ids, the first
/// on top or at the top left) over the root window's monitor.
fn arrange(&self, ctx: &egui::Context, arrange: &str, windows: &[u64]) {
let ids: Vec<WindowId> = windows.iter().filter_map(|w| self.find(*w)).collect();
let (monitor, infos) = ctx.input(|i| (i.viewport().monitor_size, i.raw.viewports.clone()));
// Platforms that don't report the monitor (Wayland, tests) get a common laptop screen.
let size = monitor.filter(|m| m.x > 0.0 && m.y > 0.0).unwrap_or(egui::vec2(1280.0, 800.0));
let mut rects = arrange_rects(arrange, ids.len(), Rect::new(0.0, 0.0, size.x, size.y));
if arrange == "cascade" {
// The active window goes in front: the last step of the cascade.
rects.reverse();
}
for (id, r) in ids.iter().zip(rects) {
let vp = self.viewports.viewport_id(*id);
// Inner size is what can be set: take off the frame the window has now.
let frame = infos.get(&vp).and_then(|v| Some(v.outer_rect?.size() - v.inner_rect?.size())).unwrap_or(egui::Vec2::ZERO);
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::OuterPosition(egui::pos2(r.x, r.y)));
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::InnerSize((egui::vec2(r.width, r.height) - frame).max(egui::vec2(240.0, 160.0))));
}
if let Some(first) = ids.first() {
self.viewports.focus(ctx, *first);
}
}
/// Mirrors the engine's visible windows: closed or hidden windows lose their native window
/// (the root adopting another), new ones get one, and a window a command activated comes to
/// the front. With no window left, quits or hides the root.
fn sync(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
let visible = visible_windows(app);
let gone: Vec<WindowId> = self.list.iter().filter(|(_, w)| !visible.contains(w)).map(|(id, _)| id).collect();
let mut root_closed = false;
for id in gone {
root_closed |= self.viewports.root() == Some(id);
self.list.close(id);
}
if root_closed {
// The window the engine brought forward, else the most recently focused.
let next = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active());
match next {
Some(id) => self.viewports.adopt(ctx, &self.list, id),
None => self.viewports.set_root(None),
}
}
for w in visible {
if self.find(w).is_none() {
self.list.open(w);
}
}
if self.viewports.root().is_none() {
let first = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active());
self.viewports.set_root(first);
}
if self.list.is_empty() {
if self.quit_with_last_window {
if !app.allow_close {
app.allow_close = true;
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close);
}
} else if !self.root_hidden {
self.root_hidden = true;
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(false));
}
return;
}
if self.root_hidden {
self.root_hidden = false;
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
}
// A command or script activated another window: bring its native window forward.
if let Some(id) = app.session.active_window().and_then(|w| self.find(w))
&& self.list.active() != Some(id)
{
self.list.focus(id);
self.viewports.focus(ctx, id);
}
}
/// Draws every window. Call from the root viewport's UI each frame, in place of
/// [`SheetApp::ui`].
pub fn show(&mut self, app: &mut SheetApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
self.sync(app, &ctx);
let focused = self.list.active().and_then(|id| self.list.get(id).copied());
let titles: std::collections::HashMap<u64, String> = self.list.iter().map(|(_, w)| (*w, window_title(app, *w))).collect();
let base = &self.base;
// The window a command run in some window's UI activated (File › New, Switch Windows, a close).
let mut requested: Option<u64> = None;
let events = self.viewports.show(
ui,
&mut self.list,
|_, w| base.clone().with_title(titles.get(w).cloned().unwrap_or_default()),
|ui, _, w| {
app.show_window(*w);
// Files dropped on a window open as workbooks (the root's arrive through the app's
// raw input hook; a child window's input reaches only its own pass).
let dropped: Vec<String> = ui
.ctx()
.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path().to_str().map(str::to_string)).filter(|p| !p.is_empty()).collect());
for path in dropped {
app.open_path(&path);
}
app.ui(ui);
if app.session.active_window() != Some(*w) {
requested = app.session.active_window();
}
},
);
for event in events {
match event {
WindowEvent::Focused(_) => {}
WindowEvent::CloseRequested(id) => {
if let Some(w) = self.close_requested(app, &ctx, id) {
requested = Some(w);
}
}
}
}
let target = requested.or_else(|| self.list.active().and_then(|id| self.list.get(id).copied()));
if let Some(target) = target.filter(|w| app.session.window(*w).is_some()) {
if requested.is_none()
&& let Some(previous) = focused.filter(|p| *p != target && app.session.window(*p).is_some())
{
// The user moved to another window: the previous window's draft is entered there,
// as Enter would. One its workbook refuses stays open in that window.
app.enter_window(previous);
let _ = app.settle_drafts();
}
app.enter_window(target);
}
self.sync(app, &ctx);
}
/// A window's close button. Returns the window to bring forward when the close needs an
/// answer (or moved focus); `None` when nothing changed.
fn close_requested(&mut self, app: &mut SheetApp, ctx: &egui::Context, id: WindowId) -> Option<u64> {
let is_root = self.viewports.root() == Some(id);
if app.allow_close {
// Quitting: every workbook has been answered for; the root's close ends the app.
return None;
}
if is_root {
// eframe would end the app with the root window; closing it is the app's decision.
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::CancelClose);
}
let window = *self.list.get(id)?;
app.enter_window(window);
app.run_or_alert("window.close", json!({"window": window}));
app.session.active_window()
}
}
/// The windows that get a native window: every window not hidden (View › Hide), or the active one
/// when all are hidden, so the app still has a window to unhide them from.
fn visible_windows(app: &SheetApp) -> Vec<u64> {
let shown: Vec<u64> = app.session.windows().iter().filter(|w| !w.hidden).map(|w| w.id).collect();
if shown.is_empty() { app.session.active_window().into_iter().collect() } else { shown }
}
/// A native window's title: the window's ("Book1", "Book1:2"), marked while its workbook has
/// unsaved changes.
fn window_title(app: &SheetApp, window: u64) -> String {
let title = app.session.window_title(window).unwrap_or_default();
let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty());
if dirty { format!("{title} — Edited") } else { title }
}
/// Outer rectangles for `n` windows arranged within `bounds` as View › Arrange All's `arrange`
/// says: `tiled` (a grid, the last row's windows widened to fill it), `horizontal` (stacked top
/// to bottom), `vertical` (side by side) or `cascade` (each one title bar below and right of the
/// previous, wrapping at the edges).
pub fn arrange_rects(arrange: &str, n: usize, bounds: Rect) -> Vec<Rect> {
if n == 0 {
return vec![];
}
let count = n as f32;
match arrange {
"horizontal" => {
let h = bounds.height / count;
(0..n).map(|i| Rect::new(bounds.x, bounds.y + h * i as f32, bounds.width, h)).collect()
}
"vertical" => {
let w = bounds.width / count;
(0..n).map(|i| Rect::new(bounds.x + w * i as f32, bounds.y, w, bounds.height)).collect()
}
"cascade" => {
let shift = CASCADE_STEP * (count - 1.0);
let size = [(bounds.width - shift).max(bounds.width * 0.5), (bounds.height - shift).max(bounds.height * 0.5)];
let mut previous = Rect::new(bounds.x - CASCADE_STEP, bounds.y - CASCADE_STEP, size[0], size[1]);
(0..n)
.map(|_| {
previous = cascade(previous, size, bounds, CASCADE_STEP);
previous
})
.collect()
}
_ => {
let cols = (count.sqrt().ceil() as usize).max(1);
let rows = n.div_ceil(cols);
let h = bounds.height / rows as f32;
(0..n)
.map(|i| {
let (row, col) = (i / cols, i % cols);
let in_row = if row + 1 == rows { n - row * cols } else { cols };
let w = bounds.width / in_row as f32;
Rect::new(bounds.x + w * col as f32, bounds.y + h * row as f32, w, h)
})
.collect()
}
}
}
+19 -7
View File
@@ -8,6 +8,11 @@ pub const REF_COLOR_COUNT: usize = 8;
#[derive(Clone, Debug)]
pub struct EditState {
/// The workbook (`DocState::uid`) the draft belongs to: it is drawn and committed only there.
pub doc: u64,
/// The window it was typed in: switching to another window, even on the same workbook,
/// commits it there first (`None` until the app sets it).
pub window: Option<u64>,
pub sheet: usize,
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
pub locale: gridcraft_engine::formula::FormulaLocale,
@@ -39,12 +44,18 @@ pub struct EditState {
pub completion: Option<String>,
/// Move the caret to the end on the next frame.
pub caret_to_end: bool,
/// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone
/// (no undo step, the workbook stays clean). `None` for an edit started by typing.
pub original: Option<String>,
}
impl EditState {
pub fn new(sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState {
pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState {
let caret = text.chars().count();
let original = (!enter_mode).then(|| text.clone());
EditState {
doc,
window: None,
sheet,
locale: Default::default(),
cell,
@@ -63,6 +74,7 @@ impl EditState {
keep_selection: false,
caret_to_end: true,
completion: None,
original,
}
}
@@ -384,7 +396,7 @@ mod tests {
#[test]
fn point_mode() {
let mut e = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false);
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false);
assert!(e.can_point());
e.insert_ref("A1");
assert_eq!(e.text, "=SUM(A1");
@@ -394,13 +406,13 @@ mod tests {
e.caret += 1;
e.point = None;
assert!(e.can_point());
let e2 = EditState::new(0, CellRef::new(0, 0), "hello".into(), true, false);
let e2 = EditState::new(1, 0, CellRef::new(0, 0), "hello".into(), true, false);
assert!(!e2.can_point());
}
#[test]
fn a_pasted_name_replaces_the_selected_text() {
let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false);
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=OldName+1".into(), false, false);
e.sync_caret = false;
(e.anchor, e.caret) = (8, 1);
e.autocomplete = vec!["SUM".into()];
@@ -416,12 +428,12 @@ mod tests {
#[test]
fn autocomplete_and_hints() {
let names: Vec<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
let mut e = EditState::new(0, CellRef::new(0, 0), "=su".into(), true, false);
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=su".into(), true, false);
e.update_autocomplete(&names);
assert_eq!(e.autocomplete.len(), 3);
assert!(e.accept_autocomplete());
assert_eq!(e.text, "=SUM(");
let e = EditState::new(0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false);
let e = EditState::new(1, 0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false);
assert_eq!(e.current_function(), Some(("SUM".into(), 1)));
}
@@ -457,7 +469,7 @@ mod tests {
#[test]
fn inserting_a_union_keeps_each_reference_separately_pointed() {
let mut edit = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false);
let mut edit = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false);
edit.insert_ref("C:C");
edit.append_ref("E:E");
assert_eq!(edit.text, "=SUM(C:C,E:E");
+2 -1
View File
@@ -142,10 +142,11 @@ fn decode(data: &[u8], side: u32) -> Option<(egui::ColorImage, bool)> {
Some((egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()), complete))
}
/// Returns the context-local shared cache of floating-picture textures.
fn cache(ctx: &egui::Context) -> Arc<Mutex<Cache>> {
// Context-local, like cell pictures: texture handles never leak between offscreen, native
// and browser contexts.
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.floating_pictures")).clone())
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::unique("gridcraft.floating_pictures")).clone())
}
/// Drops the textures of workbooks that are no longer open. Called every frame the grid paints.
+33 -15
View File
@@ -47,8 +47,9 @@ impl Default for BarLayout {
}
impl BarLayout {
/// Returns the stable widget id used for the formula-bar layout state.
pub fn id() -> egui::Id {
egui::Id::new("formula_bar.layout")
egui::Id::unique("formula_bar.layout")
}
/// Last frame's layout.
@@ -64,6 +65,7 @@ pub struct EditorInfo {
pub caret: Option<Rect>,
}
/// Draws the formula bar: line-count-driven height, Name Box, cancel/enter/Fx buttons, the editable field, muted read-only spill text and the expansion chevron.
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(13.0);
@@ -73,7 +75,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
// at MAX_LINES. It eases only when that target changes.
let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) };
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS);
let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS);
let mut layout = BarLayout { height: h, ..Default::default() };
egui::Panel::top("formula_bar")
.exact_size(h)
@@ -106,7 +108,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let inner = field.shrink2(vec2(6.0, 3.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
let id = egui::Id::new("gridcraft.formula_bar");
let id = egui::Id::unique("gridcraft.formula_bar");
// A spill child shows its anchor's formula muted and read-only.
let read_only = active_spill_anchor(app).is_some();
let info = if app.editor.is_some() {
@@ -118,7 +120,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let galley = child.painter().layout(text, font.clone(), color, inner.width());
let lines = galley.rows.len();
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col));
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col));
egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show(
&mut child,
|ui| {
@@ -167,7 +169,7 @@ fn active_input(app: &SheetApp) -> String {
}
return sh
.cell(anchor)
.map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet))
.map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet()))
.unwrap_or_default();
}
if let Some(c) = sh.cell(a) {
@@ -175,7 +177,7 @@ fn active_input(app: &SheetApp) -> String {
return String::new();
}
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);
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 {
@@ -185,15 +187,21 @@ fn active_input(app: &SheetApp) -> String {
String::new()
}
/// Draws the Name Box and its name dropdown; Enter jumps to a reference or known name (or defines a new name) and is consumed so the grid does not move down.
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
let id = egui::Id::new("gridcraft.name_box");
let id = egui::Id::unique("gridcraft.name_box");
let shown = app.name_box_text();
let mut text = app.name_box.clone().unwrap_or(shown.clone());
let rect = Rect::from_min_size(ui.cursor().min, vec2(118.0, 24.0));
ui.painter().rect_filled(rect, 4.0, t.input_bg);
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(rect.min + vec2(6.0, 3.0), vec2(96.0, 18.0))));
let resp = child.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0));
// The box has no visible label of its own: screen readers and the strict accessibility check
// need a name (egui 0.37 makes unnamed text inputs a test failure).
let accessible_name = app.ui.language.tr("Name Box").to_string();
let resp = child
.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0))
.accessible_name(accessible_name);
if resp.gained_focus() {
app.name_box = Some(shown.clone());
}
@@ -210,7 +218,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
if !target.is_empty() && target != shown {
// A new name for the selection, or a place to go.
let is_ref = gridcraft_engine::core::RangeRef::parse(target.split('!').next_back().unwrap_or("")).is_some();
let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet).is_some() || d.wb.table(&target).is_some());
let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet()).is_some() || d.wb.table(&target).is_some());
let r = if is_ref || known {
app.run("edit.goTo", json!({"reference": target}))
} else {
@@ -284,7 +292,7 @@ fn show_edit<'t>(
/// `max_height` of text and scrolls beyond that, keeping the caret in view.
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar"));
let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar"));
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
let focused = ui.ctx().memory(|m| m.focused());
let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none()));
@@ -412,7 +420,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
// The text widget.
let refs = formula_refs(&ed.text);
let t = Tokens::get(ui.ctx());
let on_sheet = id == egui::Id::new("gridcraft.cell_editor");
let on_sheet = id == egui::Id::unique("gridcraft.cell_editor");
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
let font2 = font.clone();
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
@@ -467,7 +475,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
})
.inner
};
let resp = output.response.clone();
// Both editors are text inputs without a visible label next to them: name the one being shown.
let accessible_name = (if on_sheet { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string();
let resp = output.response.response.clone().accessible_name(accessible_name);
if synced {
if ed.request_focus {
resp.request_focus();
@@ -486,7 +496,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
output.state.store(ui.ctx(), id);
} else if resp.clicked() || resp.gained_focus() {
// Clicking into the other editor moves editing there.
ed.from_formula_bar = id == egui::Id::new("gridcraft.formula_bar");
ed.from_formula_bar = id == egui::Id::unique("gridcraft.formula_bar");
ed.enter_mode = false;
app.session.mode = gridcraft_engine::Mode::Edit;
}
@@ -540,8 +550,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
ui.painter().rect_filled(r, 0.0, t.accent);
ui.painter().galley(pos, g, t.on_accent);
}
// Autocomplete list and argument hint under the editor.
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
// Autocomplete list and argument hint under the editor. Decide visibility from the area the
// editor was allotted, not from its response: egui 0.37 gives every text field a
// `spacing.interact_size.y` floor, so a one-line cell editor's response can reach below a short
// cell and touch the visible quadrant even while the cell itself is hidden under a frozen row.
if mine && ui.max_rect().intersect(ui.clip_rect()).is_positive() && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
let below = resp.rect.left_bottom() + vec2(0.0, 4.0);
let language = app.ui.language;
// Areas have their own clip rect: keep cell helpers inside the worksheet.
@@ -768,6 +781,7 @@ mod spill_tests {
check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true);
}
#[test]
/// A disabled spill formula paints disabled and does not edit; clicking an active formula starts formula-bar editing in both light and dark themes, with accessibility checks.
fn formula_bar_click_and_theme_presentation() {
use egui_kittest::kittest::Queryable as _;
for dark in [false, true] {
@@ -788,6 +802,9 @@ mod spill_tests {
app,
);
h.run_steps(3);
// `run_steps` does not enforce the 0.37 strict policy; run it explicitly so the
// formula bar's accessible names are actually tested.
h.check_accessibility();
let disabled = Tokens::get(&h.ctx).text_disabled;
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t)
if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled))));
@@ -798,6 +815,7 @@ mod spill_tests {
h.run_steps(3);
h.get_by_label("=SEQUENCE(3)").click();
h.run_steps(3);
h.check_accessibility();
assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar));
}
}
+301 -89
View File
@@ -115,6 +115,26 @@ pub struct FillTag {
pub undo_len: usize,
}
/// Trace Precedents / Trace Dependents arrows: they stay on the cell they were traced from, on its
/// sheet, while the selection moves. They go away when anything changes the workbook
/// (`revision`): Excel removes them on edits to the traced formula and on inserted, deleted or
/// moved cells, this also on a new value in a precedent.
pub struct Trace {
pub uid: u64,
pub revision: u64,
pub sheet: usize,
pub cell: CellRef,
/// The command's result: `{"sheet", "range"}` precedents or `{"sheet", "cell"}` dependents.
pub items: serde_json::Value,
}
impl Trace {
/// A trace of the active cell, just run.
pub fn new(app: &SheetApp, items: serde_json::Value) -> Option<Trace> {
app.session.active().map(|d| Trace { uid: d.uid, revision: d.revision, sheet: d.wb.active_sheet(), cell: d.selection.active, items })
}
}
#[derive(Default)]
pub struct GridState {
pub drag: Drag,
@@ -150,7 +170,7 @@ pub struct GridState {
pub last_border: String,
pub last_fill: String,
pub last_font_color: String,
pub trace: Option<serde_json::Value>,
pub trace: Option<Trace>,
pub renaming_tab: Option<usize>,
pub rename_text: String,
pub list_picker: Option<CellRef>,
@@ -594,7 +614,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
app.grid.rect = Some(rect);
let Some(d) = app.session.active() else { return };
let wb: Arc<Workbook> = d.wb.clone();
let si = wb.active_sheet;
let si = wb.active_sheet();
let Some(sh) = wb.sheet(si) else { return };
let sel = d.selection.clone();
let place = (d.uid, si);
@@ -711,9 +731,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
// Keep the active cell (or the moving end of an extension) visible after keyboard moves.
if app.grid.ensure_visible {
app.grid.ensure_visible = false;
let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active };
let moving = std::mem::take(&mut app.grid.reveal_moving_end);
let cell = if moving { sel.moving_end() } else { sel.active };
let from = app.grid.reveal_from.take();
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell);
// A whole column's moving end sits on the sheet's last row (a whole row's on its last
// column): when an extension moves it only across, the view keeps its place the other way.
// An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still
// follows it.
let extended = (moving && target == cell).then(|| sel.current());
let before = view.scroll;
if paper {
crate::paper_view::ensure_covers(app, &wb, si, target);
geo = make_geo(app, view.scroll);
@@ -741,6 +768,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
} else if from.is_some_and(|f| f.col >= geo.fc) {
view.scroll.x = 0.0;
}
if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) {
view.scroll.y = before.y;
}
if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) {
view.scroll.x = before.x;
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
geo = make_geo(app, view.scroll);
}
@@ -904,7 +937,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
let content = geo.scroll_top_of(sh, last);
let extent = (content - visible).max(scroll).max(0.0).min(limit);
let id = ui.id().with("vscroll");
let id = ui.scope_id().with("vscroll");
let resp = ui.interact(track, id, Sense::click_and_drag());
let drag_id = id.with("extent");
if resp.drag_started() {
@@ -957,7 +990,7 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1);
let content = geo.scroll_left_of(sh, last);
let extent = (content - visible).max(scroll).max(0.0);
let id = ui.id().with("hscroll");
let id = ui.scope_id().with("hscroll");
let resp = ui.interact(track, id, Sense::click_and_drag());
let drag_id = id.with("extent");
if resp.drag_started() {
@@ -1795,6 +1828,7 @@ pub(crate) fn paint_selection(
// Editing a formula: outline its references instead of the selection.
if let Some(ed) = &app.editor
&& ed.is_formula()
&& app.session.active().is_some_and(|d| d.uid == ed.doc)
{
let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str());
for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) {
@@ -1853,7 +1887,7 @@ pub(crate) fn paint_selection(
}
// Marching ants around the copied range.
if let Some(clip) = &app.session.clipboard
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet)
&& range_in_pane(clip.range, rows, cols)
{
// A multi-area copy marks each area as selected.
@@ -2013,38 +2047,46 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
}
// A held drag owns the cursor even outside the grid; a press below egui's threshold still
// has Drag::None and uses hover. A click can briefly leave a drag state after release.
if resp.dragged() && pointer.primary_down() {
let cursor = match app.grid.drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
Drag::Freeze { cols, .. } => Some(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
};
if let Some(cursor) = cursor {
ctx.set_cursor_icon(cursor);
return;
}
if resp.dragged()
&& pointer.primary_down()
&& let Some(cursor) = drag_cursor(&app.grid.drag)
{
ctx.set_cursor_icon(cursor);
return;
}
if !resp.hovered() {
return;
}
let Some(p) = pos else { return };
hover_cursor(app, ctx, resp, &pointer, geo, sh, p);
}
/// The cursor a held drag shows, or `None` when the drag leaves it to hover.
fn drag_cursor(drag: &Drag) -> Option<CursorIcon> {
match drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
}
}
/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells.
fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) {
let (freeze_cols, freeze_rows) = freeze_handles(geo);
if freeze_cols.contains(p) || freeze_rows.contains(p) {
let cols = freeze_cols.contains(p);
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
if app.grid.drag == Drag::None && !pointer.any_down() {
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
}
freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p));
return;
}
let in_cells = geo.in_cells(p);
let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left();
let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top();
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@@ -2056,16 +2098,8 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
if app.grid.drag == Drag::None && near_corner {
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
} else if (in_col_header && col_edge(geo, sh, p.x).is_some()) || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
// Just past a hidden column or row, Excel shows a split cursor: dragging or
// double-clicking there unhides it.
let hidden = app.grid.drag == Drag::None
&& if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() };
match pointer.hover_pos() {
Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge),
_ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }),
}
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge);
} else if on_handle && app.editor.is_none() {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.grid.drag == Drag::None && !pointer.any_down() {
@@ -2082,6 +2116,26 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
}
}
/// The resize cursor for dragging a freeze bar.
fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) {
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
if app.grid.drag == Drag::None && !pointer.any_down() {
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
}
}
/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel
/// shows a split cursor: dragging or double-clicking there unhides it.
fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) {
let hidden =
app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() };
match pointer.hover_pos() {
Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge),
_ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }),
}
}
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() || app.grid.hf_edit.is_some() {
@@ -2159,7 +2213,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p)
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
{
app.selected_chart = Some(id);
match act {
@@ -2369,13 +2423,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
&& ed.can_point()
{
let anchor = if mods.shift && ed.point_at_caret() {
ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c)
ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c)
} else {
c
};
let range = RangeRef::new(anchor, c);
let text = if range.is_single() { c.a1() } else { range.a1() };
let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone());
let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone());
let r = match sheet_name {
Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)),
None => text,
@@ -2387,7 +2441,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
ed.request_focus = true;
ed.point_cell = Some(c);
ed.point_anchor = Some((wb.active_sheet, anchor));
ed.point_anchor = Some((wb.active_sheet(), anchor));
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
@@ -2703,7 +2757,7 @@ fn format_painter_apply(app: &mut SheetApp) {
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
let edit_sheet = ed.sheet;
let active_sheet = wb.active_sheet;
let active_sheet = wb.active_sheet();
let range = if extend {
ed.point
.and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point))
@@ -2725,7 +2779,8 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
range
};
let reference = range.a1();
let reference = if ed.sheet != wb.active_sheet { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
let reference =
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
if add {
ed.append_ref(&reference);
} else {
@@ -2747,13 +2802,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
} else if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) {
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(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 };
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(cell);
ed.point_anchor = Some((wb.active_sheet, start));
ed.point_anchor = Some((wb.active_sheet(), start));
}
} else if let Some(d) = app.session.active() {
// Read the current anchor: starting a drag may have just replaced the selection.
@@ -2908,7 +2963,7 @@ fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<u32> {
/// has no such shape. Black on a white outline, like the system cursors.
fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) {
ctx.set_cursor_icon(CursorIcon::None);
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gridcraft.split_cursor")));
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor")));
// Drawn for columns (bars vertical); rows swap the axes.
let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) };
let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)];
@@ -2997,7 +3052,7 @@ 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"), crate::text_box::editor_id()];
let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("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();
@@ -3042,6 +3097,14 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.paste_external(text),
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
// floating picture without a chosen anchor/size would be a new feature, not parity.
// A text representation, when the clipboard has one, still arrives as `Paste` above.
egui::Event::PasteImage(_) => {}
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
// insert text at, so a middle click on a cell is deliberately not a paste.
egui::Event::MiddleClickPaste { .. } => {}
egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}
egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
app.begin_edit(Some(text), false);
@@ -3150,9 +3213,10 @@ fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 {
if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) }
}
/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes.
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
let Some(ed) = app.editor.as_ref() else { return };
if ed.sheet != wb.active_sheet {
if app.view_key() != Some((ed.doc, ed.sheet)) {
return;
}
let cell = ed.cell;
@@ -3192,18 +3256,29 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),
pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }),
);
let clip = ui.clip_rect().intersect(quadrant);
// A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works),
// but neither the cell nor its helper may paint. Test the cell's own visible quadrant and
// give the shared editor an empty clip when the cell is hidden: a one-line cell editor's
// field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its
// own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The
// pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but
// Wrap Text can expand the editor below/right of it, which must not paint over the next line.
let cell_visible = rect.width() > 0.0 && rect.height() > 0.0;
// `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored.
let screen_quadrant = geo.mirror_rect(quadrant);
let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell);
let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING };
let painter = ui.painter().with_clip_rect(clip);
painter.rect_filled(erect, 0.0, fill);
painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
let id = egui::Id::new("gridcraft.cell_editor");
let id = egui::Id::unique("gridcraft.cell_editor");
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));
child.set_clip_rect(clip);
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
// theme, on the white paper): pick black or white against the fill, like Excel.
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect));
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect));
}
/// Test the popup itself, not whether the pointer is over any part of the app.
@@ -3223,11 +3298,12 @@ fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option<RangeRef> {
sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range)
}
/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal.
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
let Some((col, row, at)) = app.grid.filter_menu else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
let si = wb.active_sheet;
let si = wb.active_sheet();
let Some(sh) = wb.sheet(si) else { return };
let _ = geo;
// Tables get an autofilter on demand.
@@ -3251,16 +3327,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
}
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 search_key = egui::Id::new(("filter_search", col));
let buttons_key = egui::Id::new(("filter_buttons", col));
let key = egui::Id::unique(("filter_sel", col));
let search_key = egui::Id::unique(("filter_search", col));
let buttons_key = egui::Id::unique(("filter_buttons", col));
// Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its
// focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click.
let buttons: Vec<egui::Id> = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default();
let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f));
let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
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);
let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
let response = area.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(230.0);
@@ -3340,10 +3416,11 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
}
}
/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press.
fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some(at) = app.grid.context_menu else { return };
let mut close = false;
let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let insert_copied = insert_copied_item(app);
@@ -3440,7 +3517,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
/// and no cell does while the PivotTable's Enable show details option is off.
fn show_details_offered(app: &mut SheetApp) -> bool {
let Some(d) = app.session.active() else { return false };
let (si, at) = (d.wb.active_sheet, d.selection.active);
let (si, at) = (d.wb.active_sheet(), d.selection.active);
let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at))))
else {
return false;
@@ -3472,14 +3549,14 @@ const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekda
/// The fill modes the menus list for a fill from `src` on the active sheet.
fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> {
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src));
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet(), src));
FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect()
}
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
app.grid.fill_tag =
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() });
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() });
}
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
@@ -3491,7 +3568,7 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
let Some(tag) = app.grid.fill_tag.clone() else { return };
let current = app.session.active().is_some_and(|d| {
d.uid == tag.uid
&& d.wb.active_sheet == tag.sheet
&& d.wb.active_sheet() == tag.sheet
&& d.revision == tag.revision
&& d.undo.len() == tag.undo_len
&& d.selection.ranges.len() == 1
@@ -3512,8 +3589,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
return;
}
let lang = crate::i18n::current(ui.ctx());
let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options")));
let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options")));
let t = Tokens::get(ui.ctx());
let p = ui.painter();
p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window });
@@ -3560,7 +3637,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
let mut close = false;
let mut items = fill_modes(app, src);
items.extend([("-", ""), ("Series…", "ui:series")]);
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let response = egui::Area::new(egui::Id::unique("fill_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 lang = crate::i18n::current(ui.ctx());
@@ -3591,7 +3668,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some((at, kind, id)) = app.grid.object_menu else { return };
let mut close = false;
let resp = egui::Area::new(egui::Id::new("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let mut items = vec![("Delete", "object.delete")];
@@ -3648,8 +3725,8 @@ fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet)
return;
}
let lang = crate::i18n::current(ui.ctx());
let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options")));
let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options")));
let t = Tokens::get(ui.ctx());
let p = ui.painter();
p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window });
@@ -3690,12 +3767,12 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
)
})
.unwrap_or(from);
ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor)
ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor)
} else {
to
};
let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
ed.insert_ref(&text);
ed.point_cell = Some(to);
ed.point_anchor = Some((sheet, anchor));
@@ -3714,6 +3791,7 @@ pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option<Rect> {
}
#[allow(clippy::too_many_arguments)]
/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows.
pub(crate) fn paint_overlays(
app: &mut SheetApp,
ui: &egui::Ui,
@@ -3743,15 +3821,18 @@ pub(crate) fn paint_overlays(
{
let r = geo.cell_rect(sh, sel.active);
let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
ui.set_max_width(220.0);
if !dv.input_title.is_empty() {
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
}
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
});
});
egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
ui.set_max_width(220.0);
if !dv.input_title.is_empty() {
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
}
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
});
},
);
}
// Notes: shown on hover (or always when marked visible).
let hover = app.grid.hover_cell;
@@ -3768,7 +3849,7 @@ pub(crate) fn paint_overlays(
let at = pos2(r.right() + 10.0, r.top());
p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
@@ -3788,16 +3869,18 @@ pub(crate) fn paint_overlays(
&& app.editor.is_none()
{
let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target));
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
ui.label(tip);
});
}
// Trace precedents / dependents arrows.
if let Some(trace) = app.grid.trace.clone()
&& let Some(items) = trace.as_array()
if let Some(trace) = &app.grid.trace
&& app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision)
&& trace.sheet == si
&& let Some(items) = trace.items.as_array()
{
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
let active = geo.cell_rect(sh, sel.active).center();
let active = geo.cell_rect(sh, trace.cell).center();
for it in items {
if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
continue;
@@ -3833,11 +3916,12 @@ fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 {
rows * row_h + (rows - 1.0) * gap
}
/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`.
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();
let si = wb.active_sheet;
let si = wb.active_sheet();
let Some(sh) = wb.sheet(si) else { return };
let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else {
app.grid.list_picker = None;
@@ -3857,7 +3941,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
(true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM),
};
let mut close = false;
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).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(height).min_scrolled_height(height).show(ui, |ui| {
@@ -3878,10 +3962,11 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
}
}
/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available.
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;
let response = 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::unique("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 insert_copied = insert_copied_item(app);
@@ -3974,7 +4059,7 @@ fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool {
fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {
let Some(d) = app.session.active() else { return };
let key = (d.uid, d.revision, d.wb.active_sheet);
let key = (d.uid, d.revision, d.wb.active_sheet());
if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) {
let pages: Vec<(RangeRef, u32)> = app
.session
@@ -4583,3 +4668,130 @@ mod outline_band_tests {
assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty());
}
}
#[cfg(test)]
mod trace_arrow_tests {
use super::*;
fn app() -> SheetApp {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
SheetApp::new(s, Default::default())
}
fn select(app: &mut SheetApp, cell: &str) {
app.run("selection.set", json!({"cell": cell})).unwrap();
}
/// What the ribbon's Trace Precedents / Trace Dependents buttons do.
fn trace(app: &mut SheetApp, id: &str) {
let r = app.run(id, json!({})).unwrap();
app.grid.trace = Trace::new(app, r);
}
/// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points.
fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) {
let d = app.session.active().unwrap();
let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone());
let sh = wb.sheet(si).unwrap();
let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0));
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let p = ui.painter().clone();
paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel);
});
// No renderer consumes the font atlas upload here.
out.textures_delta.clear();
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
let mut lines = Vec::new();
for s in out.shapes {
if let egui::Shape::LineSegment { points, stroke } = s.shape
&& stroke.color == blue
{
lines.push((points[0], points[1]));
}
}
(geo, lines)
}
fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 {
let d = app.session.active().unwrap();
let sh = d.wb.sheet(d.wb.active_sheet()).unwrap();
geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center()
}
#[test]
fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() {
let mut app = app();
for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] {
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
}
select(&mut app, "F7");
trace(&mut app, "formulas.tracePrecedents");
select(&mut app, "H7");
let (geo, lines) = arrows(&mut app);
assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}");
let f7 = center(&app, &geo, "F7");
assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}");
}
#[test]
fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() {
let mut app = app();
for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] {
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
}
select(&mut app, "B2");
trace(&mut app, "formulas.traceDependents");
select(&mut app, "C9");
let (geo, lines) = arrows(&mut app);
assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}");
let b2 = center(&app, &geo, "B2");
assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}");
}
#[test]
fn arrows_stay_on_the_sheet_of_the_traced_cell() {
let mut app = app();
app.run("home.insertSheet", json!({"name": "Two"})).unwrap();
app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap();
app.run("sheet.activate", json!({"sheet": "Two"})).unwrap();
app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap();
select(&mut app, "F7");
trace(&mut app, "formulas.tracePrecedents");
// The precedent lives on Sheet1, but the arrow belongs to Two's F7.
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be");
}
#[test]
fn inserting_a_row_removes_the_arrows() {
let mut app = app();
app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap();
app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap();
select(&mut app, "F7");
trace(&mut app, "formulas.tracePrecedents");
assert_eq!(arrows(&mut app).1.len(), 1);
app.run("home.insertRows", json!({"rows": "4"})).unwrap();
assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell");
}
#[test]
fn arrows_stay_in_their_workbook() {
let mut app = app();
app.session.new_workbook();
// Two books with the same cells and the same revision: only the document tells them apart.
for book in [1, 0] {
app.session.set_active(book);
app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap();
app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap();
}
select(&mut app, "F7");
trace(&mut app, "formulas.tracePrecedents");
assert_eq!(arrows(&mut app).1.len(), 1);
app.session.set_active(1);
let revisions: Vec<u64> = app.session.documents().iter().map(|d| d.revision).collect();
assert_eq!(revisions[0], revisions[1]);
assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2");
}
}
+16 -1
View File
@@ -283,7 +283,7 @@ pub fn saved_language(ui_json: &serde_json::Value) -> Option<Language> {
/// egui context-data key holding the language of the frame being drawn. Widgets that only get a
/// `Ui` (not the app) read it to translate their labels; [`set_current`] writes it once per frame.
fn lang_key() -> egui::Id {
egui::Id::new("gridcraft.language")
egui::Id::unique("gridcraft.language")
}
/// The language the current frame is drawing in. `En` when unset.
@@ -374,6 +374,21 @@ mod tests {
}
}
/// The screen-reader names for the formula-bar controls must be translated in every catalog
/// (no silent English fallback) and stay single-line labels.
#[test]
fn accessible_control_names_are_translated_in_every_catalog() {
for key in ["Name Box", "Cell Editor", "Font Size", "Sheet Name", "Text Box"] {
for language in Language::ALL {
let shown = language.tr(key);
assert!(!shown.contains('\n'), "{}: {key:?} is not a single-line label: {shown:?}", language.code());
if language != Language::En {
assert_ne!(shown, key, "{} falls back to English for {key:?}", language.code());
}
}
}
}
#[test]
fn translations_never_show_the_context_prefix() {
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
+9
View File
@@ -1,5 +1,6 @@
//! Czech translations for the interface strings in i18n.rs.
/// Returns the Czech translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Vložit",
@@ -187,6 +188,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Náhled\nkonců stránek",
"Page\nLayout" => "Rozložení\nstránky",
"Formula Bar" => "Řádek vzorců",
"Name Box" => "Pole názvu",
"Cell Editor" => "Editor buňky",
"Font Size" => "Velikost písma",
"Gridlines" => "Mřížka",
"Headings" => "Záhlaví",
"Zoom" => "Lupa",
@@ -331,6 +335,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
"Workbook calculation" => "Výpočet sešitu",
"Enable iterative calculation" => "Povolit iterativní výpočet",
"Maximum Iterations:" => "Maximální počet iterací:",
"Maximum Change:" => "Maximální změna:",
"Maximum Iterations must be a whole number from 1 to 32767." => "Maximální počet iterací musí být celé číslo od 1 do 32767.",
"Maximum Change must be a number of 0 or more." => "Maximální změna musí být číslo 0 nebo větší.",
"Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.",
"100%" => "100 %",
"Zoom to\nSelection" => "Přiblížit\nna výběr",
@@ -416,6 +424,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More\nFunctions" => "Další\nfunkce",
"Sheet Options|View" => "Zobrazit",
"Print" => "Tisknout",
"Print What" => "Co tisknout",
// Additional runtime labels and tooltips, grouped by source file.
// formula_bar.rs (alphabetical keys).
+10
View File
@@ -1,6 +1,7 @@
//! German translations for the interface strings in i18n.rs. Labels follow the wording German
//! spreadsheet users know; anything absent falls back to English.
/// Returns the German translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Einfügen",
@@ -191,6 +192,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Seitenumbruch-\nvorschau",
"Page\nLayout" => "Seiten-\nlayout",
"Formula Bar" => "Formelleiste",
"Name Box" => "Namensfeld",
"Cell Editor" => "Zelleneditor",
"Font Size" => "Schriftgröße",
"Gridlines" => "Gitternetzlinien",
"Headings" => "Überschriften",
"Zoom" => "Zoom",
@@ -336,6 +340,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung",
"Enable iterative calculation" => "Iterative Berechnung aktivieren",
"Maximum Iterations:" => "Maximale Iterationszahl:",
"Maximum Change:" => "Maximale Änderung:",
"Maximum Iterations must be a whole number from 1 to 32767." => "Die maximale Iterationszahl muss eine ganze Zahl von 1 bis 32767 sein.",
"Maximum Change must be a number of 0 or more." => "Die maximale Änderung muss eine Zahl größer oder gleich 0 sein.",
"Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.",
"Interface size" => "Größe der Benutzeroberfläche",
"Auto" => "Automatisch",
@@ -424,6 +432,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Dunkel",
"Sheet Options|View" => "Anzeige",
"Print" => "Drucken",
"Print What" => "Was drucken",
// grid.rs cell and row/column header context menus.
"Cut" => "Ausschneiden",
"Copy" => "Kopieren",
@@ -452,6 +461,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Next sheet" => "Nächstes Blatt",
"All sheets" => "Alle Blätter",
"New sheet (⇧F11)" => "Neues Blatt (⇧F11)",
"Text Box" => "Textfeld",
_ => return None,
})
}
+5
View File
@@ -2,6 +2,7 @@
//! Command ids, cell references and formula identifiers are not translated.
//! Source labels are kept verbatim and unmatched labels fall back to English.
/// Returns the Greek translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Επικόλληση",
@@ -185,6 +186,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Προεπισκόπηση\nαλλαγών σελίδας",
"Page\nLayout" => "Διάταξη\nσελίδας",
"Formula Bar" => "Γραμμή τύπων",
"Name Box" => "Πλαίσιο ονόματος",
"Cell Editor" => "Επεξεργαστής κελιού",
"Font Size" => "Μέγεθος γραμματοσειράς",
"Gridlines" => "Γραμμές πλέγματος",
"Headings" => "Επικεφαλίδες",
"Zoom" => "Μεγέθυνση",
@@ -396,6 +400,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Σκοτεινό",
"Sheet Options|View" => "Προβολή",
"Print" => "Εκτύπωση",
"Print What" => "Τι θα εκτυπωθεί",
"PivotTable Fields" => "Πεδία συγκεντρωτικού πίνακα",
"Choose fields to add to report:" => "Επιλέξτε πεδία για προσθήκη στην αναφορά:",
"Use a field's menu to move it between areas:" => "Χρησιμοποιήστε το μενού ενός πεδίου για να το μετακινήσετε μεταξύ περιοχών:",
+10
View File
@@ -2,6 +2,7 @@
//! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back
//! to English. Formula names and separators are localized separately (`formula_locale`).
/// Returns the Spanish translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Pegar",
@@ -190,6 +191,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Vista previa de\nsalto de página",
"Page\nLayout" => "Diseño\nde página",
"Formula Bar" => "Barra de fórmulas",
"Name Box" => "Cuadro de nombre",
"Cell Editor" => "Editor de celda",
"Font Size" => "Tamaño de fuente",
"Gridlines" => "Líneas de cuadrícula",
"Headings" => "Encabezados",
"Zoom" => "Zoom",
@@ -333,6 +337,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
"Maximum Iterations:" => "Iteraciones máximas:",
"Maximum Change:" => "Cambio máximo:",
"Maximum Iterations must be a whole number from 1 to 32767." => "Las iteraciones máximas deben ser un número entero de 1 a 32767.",
"Maximum Change must be a number of 0 or more." => "El cambio máximo debe ser un número igual o mayor que 0.",
"Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.",
"Interface size" => "Tamaño de la interfaz",
"Auto" => "Automático",
@@ -421,6 +429,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Oscuro",
"Sheet Options|View" => "Ver",
"Print" => "Imprimir",
"Print What" => "Qué imprimir",
"PivotTable Fields" => "Campos de tabla dinámica",
"Choose fields to add to report:" => "Seleccione los campos para agregar al informe:",
"Use a field's menu to move it between areas:" => "Use el menú de un campo para moverlo entre áreas:",
@@ -468,6 +477,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Next sheet" => "Hoja siguiente",
"All sheets" => "Todas las hojas",
"New sheet (⇧F11)" => "Nueva hoja (⇧F11)",
"Text Box" => "Cuadro de texto",
_ => return None,
})
}
+12
View File
@@ -1,5 +1,6 @@
//! French translations for the interface strings in i18n.rs.
/// Returns the French translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Different First Page" => "Première page différente",
@@ -213,6 +214,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Aperçu des sauts\nde page",
"Page\nLayout" => "Mise en\npage",
"Formula Bar" => "Barre de formule",
"Name Box" => "Zone de nom",
"Cell Editor" => "Éditeur de cellule",
"Font Size" => "Taille de police",
"Gridlines" => "Quadrillage",
"Headings" => "En-têtes",
"Zoom" => "Zoom",
@@ -311,6 +315,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More\nFunctions" => "Autres\nfonctions",
"Sheet Options|View" => "Affichage",
"Print" => "Impression",
"Print What" => "Quoi imprimer",
"Click to add data" => "Cliquez pour ajouter des données",
"Add header" => "Ajouter un en-tête",
"Add footer" => "Ajouter un pied de page",
@@ -420,6 +425,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"Workbook calculation" => "Calcul du classeur",
"Enable iterative calculation" => "Activer le calcul itératif",
"Maximum Iterations:" => "Nombre maximal d'itérations :",
"Maximum Change:" => "Écart maximal :",
"Maximum Iterations must be a whole number from 1 to 32767." => {
"Le nombre maximal d'itérations doit être un entier compris entre 1 et 32767."
}
"Maximum Change must be a number of 0 or more." => "L'écart maximal doit être un nombre supérieur ou égal à 0.",
"Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.",
"Insert Sheet" => "Insérer une feuille",
"Rename" => "Renommer",
@@ -438,6 +449,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Next sheet" => "Feuille suivante",
"All sheets" => "Toutes les feuilles",
"New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)",
"Text Box" => "Zone de texte",
_ => return None,
})
}
+5
View File
@@ -2,6 +2,7 @@
//! wording (sentence case, «Blocca riquadri», «Unisci e allinea al centro»); anything absent falls
//! back to English.
/// Returns the Italian translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Incolla",
@@ -205,6 +206,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Anteprima interruzioni\ndi pagina",
"Page\nLayout" => "Layout\ndi pagina",
"Formula Bar" => "Barra della formula",
"Name Box" => "Casella nome",
"Cell Editor" => "Editor di cella",
"Font Size" => "Dimensione carattere",
"Gridlines" => "Griglia",
"Headings" => "Intestazioni",
"Zoom" => "Zoom",
@@ -302,6 +306,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Scuro",
"Sheet Options|View" => "Visualizza",
"Print" => "Stampa",
"Print What" => "Cosa stampare",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "Fai clic per aggiungere dati",
"Add header" => "Aggiungi intestazione",
+8
View File
@@ -210,6 +210,9 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Page Break\nPreview", "改ページ プレビュー"),
("Page\nLayout", "ページ レイアウト"),
("Formula Bar", "数式バー"),
("Name Box", "名前ボックス"),
("Cell Editor", "セル エディター"),
("Font Size", "フォント サイズ"),
("Gridlines", "目盛線"),
("Headings", "見出し"),
("Zoom", "ズーム"),
@@ -307,6 +310,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
("More\nFunctions", "その他の関数"),
("Sheet Options|View", "画面"),
("Print", "印刷"),
("Print What", "印刷する内容"),
("Click to add data", "クリックしてデータを追加"),
("Add header", "ヘッダーの追加"),
("Add footer", "フッターの追加"),
@@ -414,6 +418,10 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
("Workbook calculation", "ブックの計算"),
("Enable iterative calculation", "反復計算を行う"),
("Maximum Iterations:", "最大反復回数:"),
("Maximum Change:", "変化の最大値:"),
("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"),
("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"),
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
];
+10
View File
@@ -1,5 +1,6 @@
//! Korean translations for the interface strings in i18n.rs.
/// Returns the Korean translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "붙여넣기",
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "페이지 나누기\n미리 보기",
"Page\nLayout" => "페이지\n레이아웃",
"Formula Bar" => "수식 입력줄",
"Name Box" => "이름 상자",
"Cell Editor" => "셀 편집기",
"Font Size" => "글꼴 크기",
"Gridlines" => "눈금선",
"Headings" => "머리글",
"Zoom" => "확대/축소",
@@ -307,6 +311,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "어둡게",
"Sheet Options|View" => "화면",
"Print" => "인쇄",
"Print What" => "인쇄할 내용",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "클릭하여 데이터 추가",
"Add header" => "머리글 추가",
@@ -420,6 +425,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산",
"Enable iterative calculation" => "반복 계산 사용",
"Maximum Iterations:" => "최대 반복 횟수:",
"Maximum Change:" => "변화 한도:",
"Maximum Iterations must be a whole number from 1 to 32767." => "최대 반복 횟수는 1에서 32767 사이의 정수여야 합니다.",
"Maximum Change must be a number of 0 or more." => "변화 한도는 0 이상의 숫자여야 합니다.",
"Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.",
"Insert Sheet" => "시트 삽입",
"Rename" => "이름 바꾸기",
@@ -436,6 +445,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Next sheet" => "다음 시트",
"All sheets" => "모든 시트",
"New sheet (⇧F11)" => "새 시트 (⇧F11)",
"Text Box" => "텍스트 상자",
_ => return None,
})
}
+5
View File
@@ -1,5 +1,6 @@
//! Dutch translations for the interface strings in i18n.rs.
/// Returns the Dutch translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Plakken",
@@ -206,6 +207,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Pagina-einde-\nweergave",
"Page\nLayout" => "Pagina-\nindeling",
"Formula Bar" => "Formulebalk",
"Name Box" => "Naamvak",
"Cell Editor" => "Celeditor",
"Font Size" => "Tekengrootte",
"Gridlines" => "Celrasterlijnen",
"Headings" => "Kopteksten",
"Zoom" => "Zoomen",
@@ -302,6 +306,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More\nFunctions" => "Meer\nfuncties",
"Sheet Options|View" => "Weergeven",
"Print" => "Afdrukken",
"Print What" => "Wat afdrukken",
"Click to add data" => "Klik om gegevens toe te voegen",
"Add header" => "Koptekst toevoegen",
"Add footer" => "Voettekst toevoegen",
+5
View File
@@ -1,3 +1,4 @@
/// Returns the Polish translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Wklej",
@@ -201,6 +202,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Podgląd\npodziału stron",
"Page\nLayout" => "Układ\nstrony",
"Formula Bar" => "Pasek formuły",
"Name Box" => "Pole nazwy",
"Cell Editor" => "Edytor komórki",
"Font Size" => "Rozmiar czcionki",
"Gridlines" => "Linie siatki",
"Headings" => "Nagłówki",
"Zoom" => "Powiększenie",
@@ -298,6 +302,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Ciemny",
"Sheet Options|View" => "Wyświetl",
"Print" => "Drukuj",
"Print What" => "Co drukować",
"Click to add data" => "Kliknij, aby dodać dane",
"Add header" => "Dodaj nagłówek",
"Add footer" => "Dodaj stopkę",
+9
View File
@@ -1,6 +1,7 @@
//! Brazilian Portuguese translations for the interface strings in i18n.rs. Labels follow Excel's
//! pt-BR wording; anything absent falls back to English.
/// Returns the Portuguese translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Colar",
@@ -205,6 +206,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Visualização\nde Quebras de Página",
"Page\nLayout" => "Layout\nde Página",
"Formula Bar" => "Barra de Fórmulas",
"Name Box" => "Caixa de nome",
"Cell Editor" => "Editor de célula",
"Font Size" => "Tamanho da fonte",
"Gridlines" => "Linhas de Grade",
"Headings" => "Títulos",
"Zoom" => "Zoom",
@@ -303,6 +307,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Escuro",
"Sheet Options|View" => "Exibir",
"Print" => "Imprimir",
"Print What" => "O que imprimir",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "Clique para adicionar dados",
"Add header" => "Adicionar cabeçalho",
@@ -413,6 +418,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
"Workbook calculation" => "Cálculo da pasta de trabalho",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
"Maximum Iterations:" => "Número Máximo de Iterações:",
"Maximum Change:" => "Alteração Máxima:",
"Maximum Iterations must be a whole number from 1 to 32767." => "O número máximo de iterações deve ser um número inteiro de 1 a 32767.",
"Maximum Change must be a number of 0 or more." => "A alteração máxima deve ser um número maior ou igual a 0.",
"Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.",
"OK" => "OK",
"Cancel" => "Cancelar",
+5
View File
@@ -5,6 +5,7 @@
//! usual in Romanian software ("Salvare", "Inserare…") and messages address the user formally.
//! An AI-assisted draft (Claude) for a Romanian-speaking contributor, pending native-speaker review.
/// Returns the Romanian translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Lipire",
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Examinare\nsfârșituri de pagină",
"Page\nLayout" => "Aspect\npagină",
"Formula Bar" => "Bară de formule",
"Name Box" => "Casetă de nume",
"Cell Editor" => "Editor de celule",
"Font Size" => "Dimensiune font",
"Gridlines" => "Linii de grilă",
"Headings" => "Titluri",
"Zoom" => "Zoom",
@@ -305,6 +309,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Întunecată",
"Sheet Options|View" => "Vizualizare",
"Print" => "Imprimare",
"Print What" => "Ce imprimați",
"Click to add data" => "Faceți clic pentru a adăuga date",
"Add header" => "Adăugare antet",
"Add footer" => "Adăugare subsol",
+10
View File
@@ -1,5 +1,6 @@
//! Russian translations for the interface strings in i18n.rs.
/// Returns the Russian translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Вставить",
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц",
"Page\nLayout" => "Разметка\nстраницы",
"Formula Bar" => "Строка формул",
"Name Box" => "Поле имени",
"Cell Editor" => "Редактор ячейки",
"Font Size" => "Размер шрифта",
"Gridlines" => "Сетка",
"Headings" => "Заголовки",
"Zoom" => "Масштаб",
@@ -307,6 +311,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Тёмная",
"Sheet Options|View" => "На экране",
"Print" => "Печать",
"Print What" => "Что печатать",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "Щелкните, чтобы добавить данные",
"Add header" => "Добавить верхний колонтитул",
@@ -420,6 +425,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге",
"Enable iterative calculation" => "Включить итеративные вычисления",
"Maximum Iterations:" => "Предельное число итераций:",
"Maximum Change:" => "Максимальное изменение:",
"Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.",
"Maximum Change must be a number of 0 or more." => "Максимальное изменение должно быть числом не меньше 0.",
"Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.",
"Insert Sheet" => "Вставить лист",
"Rename" => "Переименовать",
@@ -438,6 +447,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Next sheet" => "Следующий лист",
"All sheets" => "Все листы",
"New sheet (⇧F11)" => "Новый лист (⇧F11)",
"Text Box" => "Надпись",
_ => return None,
})
}
+10
View File
@@ -1,5 +1,6 @@
//! Serbian (Latin script, ekavian) translations for the interface strings in i18n.rs.
/// Returns the Serbian translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Nalepi",
@@ -203,6 +204,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Pregled preloma\nstranica",
"Page\nLayout" => "Izgled\nstranice",
"Formula Bar" => "Traka formule",
"Name Box" => "Polje imena",
"Cell Editor" => "Uređivač ćelije",
"Font Size" => "Veličina fonta",
"Gridlines" => "Linije mreže",
"Headings" => "Zaglavlja",
"Zoom" => "Uvećanje",
@@ -301,6 +305,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More\nFunctions" => "Još\nfunkcija",
"Sheet Options|View" => "Prikaz",
"Print" => "Štampaj",
"Print What" => "Šta štampati",
"Click to add data" => "Kliknite da dodate podatke",
"Add header" => "Dodaj zaglavlje",
"Add footer" => "Dodaj podnožje",
@@ -412,6 +417,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene",
"Workbook calculation" => "Izračunavanje radne sveske",
"Enable iterative calculation" => "Omogući iterativno izračunavanje",
"Maximum Iterations:" => "Maksimalan broj iteracija:",
"Maximum Change:" => "Maksimalna promena:",
"Maximum Iterations must be a whole number from 1 to 32767." => "Maksimalan broj iteracija mora biti ceo broj od 1 do 32767.",
"Maximum Change must be a number of 0 or more." => "Maksimalna promena mora biti broj veći ili jednak 0.",
"Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.",
"Left Margin" => "Leva margina",
"Right Margin" => "Desna margina",
@@ -436,6 +445,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Next sheet" => "Следећи лист",
"All sheets" => "Сви листови",
"New sheet (⇧F11)" => "Нови лист (⇧F11)",
"Text Box" => "Текстуално поље",
_ => return None,
})
}
+6
View File
@@ -1,6 +1,7 @@
//! Turkish translations for the interface strings in i18n.rs. Labels follow the wording Turkish
//! spreadsheet users know; anything absent falls back to English.
/// Returns the Turkish translation for `text`, or `None` so callers fall back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Yapıştır",
@@ -188,6 +189,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Page Break\nPreview" => "Sayfa Sonu\nÖnizleme",
"Page\nLayout" => "Sayfa\nDüzeni",
"Formula Bar" => "Formül Çubuğu",
"Name Box" => "Ad kutusu",
"Cell Editor" => "Hücre düzenleyici",
"Font Size" => "Yazı tipi boyutu",
"Gridlines" => "Kılavuz Çizgileri",
"Headings" => "Başlıklar",
"Zoom" => "Yakınlaştır",
@@ -410,6 +414,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Koyu",
"Sheet Options|View" => "Görünüm",
"Print" => "Yazdır",
"Print What" => "Yazdırılacaklar",
"Text Box" => "Metin Kutusu",
_ => return None,
})
}

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