Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 30b210b4ef Release 0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:31 -05:00
aiongg 29b17a6ff8 Merge pull request #467 from prxnvt/pr/function-library
UI: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories
2026-11-01 13:44:57 -05:00
aiongg 8cd1be510c Merge pull request #2597 from prxnvt/pr/fuzz-view-mode-undo
Undo and redo keep each sheet's view (Normal, Page Layout, Page Break Preview)
2026-11-01 13:44:16 -05:00
FredApps e1e72426e5 Fix merged keytip sequence count for HBJ and MR 2026-11-01 19:09:04 +01:00
FredApps dc095444f2 Merge current main into PR2597 for hosted validation 2026-11-01 19:06:22 +01:00
FredApps 10b763e7a4 Merge current main into PR467 and preserve both documentation histories 2026-11-01 19:06:22 +01:00
Dylan M. Taylor 6917788124 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:04 -05:00
Brandon ThomasandClaude Opus 5.5 ea1d52fe8f Review fixes for #3042: "" with parse: false keeps the cell's formatting; correct the write_range parse description
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:20:54 -05:00
Brandon Thomas 653227fe6e Merge PR #3077 2026-11-01 12:20:06 -05:00
Brandon Thomas e129c072a1 Merge PR #3075
# Conflicts:
#	crates/xlsx/src/read.rs
#	docs/file-format-parity.md
2026-11-01 12:20:06 -05:00
Brandon Thomas bc173ead8f Merge PR #3062
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
2026-11-01 12:19:55 -05:00
Brandon Thomas 7f1574b573 Merge PR #3052
# Conflicts:
#	docs/file-format-parity.md
2026-11-01 12:19:55 -05:00
Brandon Thomas 6a3aa58a07 Merge PR #3043
# Conflicts:
#	ROADMAP.md
2026-11-01 12:19:55 -05:00
Brandon Thomas e6a8d4a8b6 Merge PR #3042 2026-11-01 12:19:44 -05:00
Brandon Thomas 49ed44cb02 Merge PR #3040 2026-11-01 12:19:44 -05:00
Brandon Thomas 229b6001ec Merge PR #3083 2026-11-01 12:18:01 -05:00
Brandon Thomas 1975360120 Merge PR #3050 2026-11-01 12:18:01 -05:00
Brandon Thomas 81ee96fa45 Merge PR #3041 2026-11-01 12:18:01 -05:00
Brandon Thomas 40d99e3068 Merge PR #3038 2026-11-01 12:18:01 -05:00
scks1 60a267b4ef #3057: select visible workbook views and normalize tab ratios 2026-11-01 08:06:44 -05:00
vioreltusandClaude Opus 5.5 5e415cdd34 macOS: Intel Macs skip wgpu's indirect-call validation shaders (fixes #2812) (#3039)
* Fix #2812: Intel Macs skip wgpu's indirect-call validation shaders

Metal's Intel compiler aborts on wgpu's indirect-validation compute shaders
("Compiler encountered an internal error"; MTLCompilerService crashes in
reportUnsupportedMaxWorkGroupSizeMetadataError), the device is lost inside
request_device and GridCraft exits before its window opens. GridCraft issues
no indirect draws or dispatches, so on Intel Macs the instance leaves
VALIDATION_INDIRECT_CALL out; WGPU_VALIDATION_INDIRECT_CALL=1 turns it back
on. Every other platform keeps wgpu's default. Same as WordCraft (#2652 there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk

* Review: test that WGPU_VALIDATION_INDIRECT_CALL=1 still turns it on; qualify the doc line

The override is checked in a child test process with the variable set, since
tests can't set environment variables without unsafe code. The revision line
no longer says Intel Macs start: OpenCore Legacy Patcher Macs on macOS 15 can
still fail on the MSL version (#1116).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-01 10:54:28 +01:00
Harrison SmithandClaude Opus 5.5 a1e0383056 #2871: unwrap Google Sheets' wrapper only when GridCraft calculates the formula
A formula calling a Google-only function (GOOGLETRANSLATE, QUERY,
ARRAYFORMULA...) opened as #NAME? where the wrapper had shown the cached
value, as Excel does. The XLSX reader now unwraps a cell formula only when
every function inside is known: read_xlsx_with takes the predicate (the
xlsx crate can't depend on the function library) and the engine passes
gridcraft_calc::is_known_function. Unwrapping stays in the reader so shared
formulas are shifted from the unwrapped master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 00:08:40 -06:00
aiongg 7ed8a9f312 fix(quit): bring GridCraft forward through AppKit for a Dock quit
egui's Visible/Minimized/Focus go through winit, whose macOS focus_window skips a minimized window or a hidden app, and macOS can refuse the activation. When a Dock quit opens a question, unhide the app, deminiaturize its windows and activate it through AppKit, and bounce the Dock icon if it still isn't active.
2026-11-01 04:30:17 +00:00
aiongg a30644c872 fix(quit): bring the asking window to the front on a Dock quit
Quit from the Dock reaches GridCraft while it is behind other apps, hidden or minimized. The Save Changes question opened in the workbook's window, but nothing brought that window forward when it was already the active one, so the app looked stuck. The window is restored, shown and focused in that order, since macOS ignores Focus for a minimized window or a hidden app.
2026-11-01 04:26:47 +00:00
Harrison SmithandClaude Opus 5.5 1c7e9a2f90 #2964: compare menu item params as JSON values; test non-menu prefixes
Review follow-up. item_letter_with compared the compact params string, so a key
order or spacing difference between keytips.json and the caller would miss the
item. It now takes a serde_json::Value and compares parsed values. Tests add
prefixes that must not open the Borders menu (a finished sequence, another
group's level, another control) and an unknown preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:45:37 -06:00
Dylan M. Taylor 15c9d8c10b ci: grant packages write permission to release workflow 2026-10-31 23:43:09 -04:00
Harrison SmithandClaude Opus 5.5 2b4a9fc0f3 #2964: give Inside Borders a key tip (HBJ)
Review follow-up. Inside Borders is GridCraft's own Borders menu item (Excel's
menu has none), so it had no letter and was the one preset the keyboard
couldn't reach from the open menu. It takes J, unused under HB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:40:56 -06:00
Harrison SmithandClaude Opus 5.5 c55a7e35aa #2964: Alt, H, B opens the Borders menu with its item letters
At the "HB" level every Borders sequence (HBA..HBW) shares the prefix, so
control_badge drew no badge and nothing opened: after B the screen showed no
key tips at all, although the next letter still applied its preset. The
Borders menu now opens at its group level, as Excel's does, with each preset's
letter (told apart by its params) and More Borders' M. A letter or Esc changes
the prefix, which closes the menu as before; clicking the arrow still toggles it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:48:37 -06:00
Harrison SmithandClaude Opus 5.5 2c0a00fbae #2871: open Google Sheets' DUMMYFUNCTION export wrapper as the original formula
Google Sheets' XLSX export writes a formula that uses a function it thinks
Excel lacks as IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),
<cached value>). GridCraft showed the wrapper as the formula.

fmla::from_file, which every cell formula read goes through (plain, shared
master and t="array"), now recognises exactly that shape: IFERROR (with or
without _xlfn.) of a single-string __xludf.DUMMYFUNCTION call, any case.
If the string parses, the formula inside replaces the wrapper, as the whole
formula or nested in a larger one (parenthesised unless it is a single
operand). The cell keeps the file's cached value.

Bounded on hostile input: inner text over MAX_FORMULA_LEN is not parsed,
only one level is unwrapped, and a result that no longer parses (nesting
past the parser's limit) keeps the file's formula. Text that doesn't parse
keeps the wrapper.

Saving writes the real formula with Excel's _xlfn. prefixes, as Excel does
with the _xlfn functions it knows. Keeping cached results for functions
GridCraft can't calculate is #2770 and not part of this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:19:20 -06:00
Dylan M. Taylor a85b1aea3a packaging(flatpak): add OCI image build and GHCR publishing helper (#3063) 2026-10-31 18:53:12 -04:00
Simon Koch-Sultan 9276827b1a xlsx: keep the workbook view settings across an open + save (#3057)
The reader took only activeTab from <workbookView> and the writer wrote
that alone back, so a workbook Excel saved with the sheet tabs and both
scroll bars hidden (and a non-default tab split) opened with all three
shown and lost the settings on save.

Workbook gains a view field (WorkbookView: show_sheet_tabs,
show_horizontal_scroll, show_vertical_scroll, tab_ratio; the format
defaults are shown/shown/shown/600). The reader fills it from the
<workbookView> attributes; the writer emits each attribute only when it
differs from the default, so an ordinary workbook file is unchanged.

Regression test: workbook_view_settings_survive_a_round_trip reads a
package carrying the hidden settings, checks the model, and checks the
written workbook.xml keeps all four attributes (and that a default
workbook still writes the bare element).

Scope: this is the data-loss half of #3057. The app does not act on the
settings yet and File > Options has no controls for them; that is the
other half the ticket was split into.
2026-10-31 15:31:07 -04:00
Max Murphy ea3460ea19 contributors: add maxm11 entry (sync GitHub name) 2026-10-31 20:01:50 +01:00
Max Murphy edcb626c3c contributors: add maxm11 entry (sync GitHub name) 2026-10-31 20:01:48 +01:00
Max Murphy f2fab9e161 contributors: add maxm11 entry (sync GitHub name) 2026-10-31 20:01:47 +01:00
Max Murphy 1a06933410 contributors: add maxm11 entry (sync GitHub name) 2026-10-31 20:01:45 +01:00
Max Murphy aacdec4138 contributors: add maxm11 entry (sync GitHub name) 2026-10-31 20:01:22 +01:00
Daniel LubrykaandClaude Sonnet 5.5 d25445f4e3 M2.1: document the .xls style helpers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:14:33 +01:00
Daniel LubrykaandClaude Sonnet 5.5 879ea7385f M2.1: test that .xls fonts keep their family and charset
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:01:26 +01:00
Daniel LubrykaandClaude Sonnet 5.5 f2a75eca1b M2.1: keep BIFF8 font family and charset when importing .xls fonts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:47:15 +01:00
Zarvinx 9654c2030a #3035: assert Copies updates before Escape 2026-10-31 18:21:13 +01:00
Zarvinx f2bef898e8 #3035: let Esc leave File-page fields before closing 2026-10-31 18:09:27 +01:00
Max Murphy 85bfe358dc Ribbon: each window keeps its own tab, so Table Design stays active (#2903) 2026-10-31 16:58:14 +01:00
Max Murphy 72b1310a5a #2765: CSV and text open detect each column's date order 2026-10-31 16:57:58 +01:00
Max Murphy 8fff311210 range.setValues: report the strings typed input read, and clear "" with parse: false (#2255)
The default still reads a string with typed input, which is en-US: "05.04.2026"
from a dd.mm.yyyy export became 4 May, "2.500" became 2.5, and the command said
nothing (#2255). `parse: false` (PR #2267) lets a caller keep the text, but a
script that does not pass it still cannot notice the change.

The result now names them: `converted` lists the strings typed input read as a
number (dates and times included), a boolean or an error, with the cell, the
input and what the cell shows, and `convertedCount` is the total. It is capped
at 20 entries and 32 characters per string, so a big write does not put a second
copy of its data in the result. A string written as a formula (`=1+1`) and a
leading apostrophe are not conversions. `cells` and the default reading of the
values are unchanged.

Two `parse: false` fixes from the review of #2267:

- An empty string clears the cell, as `null` and the default path do. Storing an
  empty text cell left a cell that is not blank: `values: [["", ""]]` gave
  COUNTA 2 and ISBLANK FALSE.
- MCP `write_range` takes `parse`, so an agent can opt out too; the tool's
  description and docs/mcp.md say so.

Tests: the issue's examples under both settings, the report and its cap, the
empty string, and an XLSX save/reopen that keeps the text as text and the
default's date as a date.
2026-10-31 16:57:38 +01:00
Max Murphy 40b254c306 fix(ui): refuse a locked cell's edit before a draft blocks Save (#2945) 2026-10-31 16:52:49 +01:00
Max Murphy 81aee9e075 calc: form the MAP result size in u64 so wasm32 cannot wrap the cap (#2165)
`broadcast_len`, the MAX_CELLS guard shared by MAP, the binary operators,
TEXT and array IF, multiplied `rows` and `cols` as `usize`. On wasm32 a
broadcast such as 65536 x 65536 (2^32 cells) wraps that product to 0 and
slips past the cap, after which the allocation and loop run far past
MAX_CELLS. The product is now formed in u64 and converted back only after
the cap check, so the guard means the same on every target.

`single`, the per-cell scalar check in MAP and MAKEARRAY, compared
`rows * cols` with 1. A constructed array stores exactly `rows * cols`
values, so it now tests `data.len()` directly: the same answer without an
unchecked product.

Tests cover MAX_CELLS exactly, one cell over, the 2^32 pair that wraps to
zero on a 32-bit usize, a full column x row (2^34), and MAP itself just
over the cap and at 16384 x 16384.

Closes #2165
2026-10-31 16:41:24 +01:00
Brandon Thomas 67e39faeea Review fixes: describe the YEARFRAC, MOD and PERCENTRANK changes in function-parity 2026-10-31 10:17:29 -04:00
Brandon Thomas 59fd17ae56 Merge PR #2980
# Conflicts:
#	docs/function-parity.md
2026-10-31 10:17:09 -04:00
Brandon Thomas 8069ad33ef Merge PR #2979
# Conflicts:
#	docs/function-parity.md
2026-10-31 10:17:09 -04:00
Brandon Thomas 82a78a9d21 Merge PR #2657
# Conflicts:
#	docs/function-parity.md
2026-10-31 10:17:09 -04:00
Brandon Thomas ac27e3da14 Merge PR #2793 2026-10-31 10:17:09 -04:00
Brandon Thomas 79fd7f456e Merge PR #3024 2026-10-31 10:00:34 -04:00
prxnvt 590d5e4492 UI: Insert Function: Enter on the category list doesn't confirm the dialog
egui clicks a focused control on Enter as on Space, so Enter on the
focused category list opened it, and the dialog's OK handling took the
same key press and inserted the selected function. Enter in the open
list did the same while picking a category.

The dialog now leaves Enter to the category list while the list has the
focus or its menu is open (or was open as the frame started, since Enter
on a row closes it in that frame). Enter in the search box or on the
function list still confirms.

Test: function_library::enter_on_the_category_list_does_not_confirm_insert_function
tabs to the list, presses Enter (the list opens, the dialog stays open,
nothing is inserted), presses Enter again in the open list (still open,
nothing inserted), then Shift+Tab back to the search box, where Enter
confirms.
2026-10-31 07:03:16 -04:00
NetterSilverandClaude Haiku 5.5 671e194bb9 Make the Merge & Center ribbon label part of its split button
Fixes #2920

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 11:37:17 +01:00
prxnvt c5a80ff791 UI: Insert Function lets Tab reach the category list
The search box asked for the keyboard focus every frame, so Tab moved to the
category list for one frame and the focus went back to the search box. The
dialog now gives the search box the focus once, when it opens, through the
module's take_first_focus, as the other dialogs do.

Test: insert_function_lets_tab_reach_the_category_list (the search box has
the focus and takes typing; after Tab the category list keeps it over several
frames; Space opens the list).
2026-10-31 04:00:56 -04:00
prxnvt 474472f999 Engine: Recently Used follows the formulas entered; Insert Function category list
formulas.recentlyUsed scanned the session journal for cell.set inputs.
It missed formulas entered by range.* commands, lost them when the
journal dropped its oldest entries (a large file open or a long script
is enough), and started empty in every session. The Function Library's
Recently Used menu and the Insert Function dialog list what it returns.

- Prefs::recent_functions (a new engine module, recent.rs) holds the
  ten functions most recently entered in a formula, newest first.
  Session::execute updates it for every cell.* and range.* command it
  journals, from any source (the interface, the control channel, MCP or
  a script). It reads at most 2,000 of the formulas a command enters,
  starting from the last one; formulas longer than Excel's limit are
  skipped. formulas.recentlyUsed
  returns it. It never reads the journal back, so the journal's caps do
  not affect it.
- The list is kept with the other engine preferences (prefs.json, the
  browser's storage on the web), so it survives a restart as Excel's
  does. It is read leniently: a malformed value loses only this list. A
  fresh install starts from common functions.

The Insert Function dialog is titled "Insert Function", as in Excel (it
said "Formula Builder"), and gains a working category list: Most
Recently Used, All, then each category in Excel's order. A category may
be given by label or by engine id, so a Function Library menu or the
control channel (ui.dialog.set) opens it on one, and a new search
switches to All. The dialog keeps its translated labels and its search
in the interface language. Each row shows the function's name and its
category in two columns; the category column starts after the widest
name shown, so a long name in any font or formula language stays clear
of it. A function counts as recently used once its formula is entered,
not when OK is pressed. The unused "Formula Builder" translations are
removed.

Tests: engine unit tests for reading functions from formulas (strings,
unknown names, over-long and malformed formulas), the formulas a
command enters (newest first, within the budget), the ten-name list
and the lenient read of prefs.json; tests/it/recently_used.rs for
cell.set and range.setValues from the session, a failed command, a
cleared journal and a save and reload of the preferences. UI tests for
the menu's order and the dialog's title, categories, engine-id
category, search and OK, and for the category column with a wide font.
2026-10-31 04:00:56 -04:00
prxnvt 6aa5a78828 UI: Formulas › Function Library as labelled buttons with category menus
The Function Library group was a big Insert Function button followed by
AutoSum and seven big category buttons that all drew the same icon, the
widest group on the Formulas tab. It is now laid out as Excel lays it
out when the window is not wide enough for a big button per category:
big Insert Function and AutoSum buttons, then the categories as
labelled small buttons in columns of three:

  Recently Used    Text                 Math & Trig
  Financial        Date & Time          More Functions
  Logical          Lookup & Reference

- The category buttons draw a new code-drawn icon, Icon::FnBook, a book
  whose spine and glyph (star, coins, question mark, letter, clock,
  magnifier, theta, dots) take the category's colour from the theme's
  category palette. icons::paint_accent draws an icon with an accent
  colour, and widgets::small_button_accent is a small button that uses
  it; small_button is unchanged for its callers.
- AutoSum stays the big split button: the icon inserts SUM, the label
  and arrow open the AutoSum menu, which now reads Sum, Average, Count
  Numbers, Max, Min and More Functions… and is shared with Home ›
  Editing. Its tooltip names the control only, so the shortcut comes
  from the platform lookup (Alt+= on Windows and Linux).
- Each category menu lists its functions with their descriptions on
  hover and ends with Insert Function…, which opens the dialog with
  that category. More Functions cascades into Statistical, Engineering,
  Cube, Information, Compatibility and Web. Recently Used lists what
  the engine's formulas.recentlyUsed returns.
- The function categories, the function list (built once) and the menus
  live in a new function_library module; the dialog's category labels
  come from it.
- keytips.json gains MR for Recently Used, Excel's sequence. Labelled
  small and big buttons report their label to accessibility.
- The one-line labels (Date & Time, Lookup & Reference, Math & Trig,
  More Functions) replace the two-line keys in every interface
  language, with the same words. Recently Used is translated in Czech,
  German, Spanish, French, Japanese, Korean, Portuguese, Russian and
  Chinese; the other languages show it in English, their fallback.

Tests: tests/it/function_library.rs covers the layout (columns of three
level with the tab's other columns), each category menu and its Insert
Function… row, the More Functions cascade, the AutoSum split button and
its menu, and the MR key tip. A ribbon unit test keeps Command-key
glyphs out of the ribbon's text. chart_resize picks the chart menu's
Delete now that Home › Cells' Delete has an accessible name.
2026-10-31 03:35:06 -04:00
Dylan M. Taylor 981fad14e0 Merge pull request #3012 from aiongg/docs-tidy
docs: tidy agent, contributor and asset docs
2026-10-31 01:06:39 -04:00
Dylan M. Taylor 7fcf4232b7 Merge pull request #3013 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:49 -04:00
prxnvt 8c0c2fc64b Fuzzer: switching Normal, Page Layout or Page Break Preview isn't an undo difference
The view commands are not undo steps and don't mark the workbook changed (as in
Excel), so undoing everything leaves a sheet's view mode as it is. The undo
check now resets the view mode with the other view state before comparing.
Seed 177 (`view.pageBreakPreview` on the sales sample) failed the [undo]
invariant without this.
2026-10-31 01:02:33 -04:00
prxnvt 1dcff1742f Undo and redo keep each sheet's view (Normal, Page Layout, Page Break Preview)
A sheet's view is not an undo step: Excel doesn't undo a switch between
Normal, Page Layout and Page Break Preview, and view.normal,
view.pageLayout and view.pageBreakPreview are noundo here. But undo and
redo swap in a whole snapshot of the workbook, so undoing an earlier edit
put back the view the sheet had then: set A1, switch to Page Break
Preview, undo, and the sheet was in Normal again (redo switched it back).

Undo and redo now keep each sheet's current view (extra::keep_view_modes),
as they already keep the Watch Window list, carrying it through each step
of a multi-step undo or redo. Sheets are matched by name; while a rename
is undone or redone, by position under the name the restored snapshot
doesn't have. A view kept this way doesn't make a saved
workbook dirty, as set_view doesn't.

Tests: undo and redo with each view, undoing a rename and a new sheet, a
rename and a new sheet undone and redone with steps: 2, and redo back to
the saved state staying clean. Each fails without the change (the view
reverted to Normal).
2026-10-31 01:02:33 -04:00
aiongg 79722c7666 updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:26 +00:00
aiongg 2329904024 docs: tidy wording 2026-10-31 04:25:43 +00:00
aiongg 5f7137ca01 docs: tidy agent, contributor and asset docs 2026-10-31 04:17:38 +00:00
Brandon ThomasandClaude Opus 5.5 55850841d3 Release 0.14.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:30 -04:00
Brandon ThomasandClaude Opus 5.5 1f73f62c4f Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon Thomas fb1e59ff61 Merge remote-tracking branch 'origin/main' into land-1030b 2026-10-30 23:16:31 -04:00
Brandon ThomasandClaude Opus 5.5 8f59487cbe Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:39 -04:00
Brandon ThomasandClaude Opus 5.5 71d4e2311d Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:37 -04:00
Brandon Thomas 213fe1150e Review fixes: one Depth table for the BINOM, BETA and HARMEAN notes; summarise the function-parity header 2026-10-30 23:07:11 -04:00
Brandon Thomas 0db7d97f2e Merge PR #3008
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 4d1233d0a2 Merge PR #3007
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 07c8201786 Merge PR #3006
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 31d5cc5b41 Merge PR #3003
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas c0fcc7de16 Merge PR #3002
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 5390e230d8 Merge PR #3001
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas c9eb797d8b Merge PR #3000
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas 5441691ca3 Merge PR #2999
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:04:26 -04:00
Brandon Thomas d3f54debd9 Merge PR #2998
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:04:10 -04:00
Brandon Thomas 64ea0cb69c Merge PR #2997
# Conflicts:
#	crates/functions/src/text.rs
#	docs/function-parity.md
2026-10-30 23:04:01 -04:00
Brandon Thomas f887ecf1cf Merge PR #2996
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas ae598a74fe Merge PR #2995
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas bc0fffbe97 Merge PR #2994
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:40 -04:00
Brandon Thomas 1ebef5a694 Merge PR #2992
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:39 -04:00
Brandon Thomas f812c53814 Merge PR #2991
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas b775b3ce32 Merge PR #2990
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas 76aed738e3 Merge PR #2989 2026-10-30 23:03:37 -04:00
Brandon Thomas a44d3d5ee5 Merge PR #2982 2026-10-30 23:03:36 -04:00
Brandon Thomas 2bf5dbac84 Merge PR #2983 2026-10-30 23:03:36 -04:00
Brandon Thomas c269ff483d Merge PR #2993 2026-10-30 23:03:35 -04:00
Brandon ThomasandClaude Opus 5.5 15f6297001 Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:24 -04:00
Harrison Booth 006b7962bd Preserve F distribution tail accuracy 2026-10-30 21:14:19 -05:00
Harrison Booth 7ea3a4c2c3 Merge upstream main into harmonic mean branch 2026-10-30 20:59:56 -05:00
Harrison Booth cae0c9abdb Merge main into function updates 2026-10-30 20:59:41 -05:00
Harrison Booth ed81d7e34e Merge upstream main into binomial distribution branch 2026-10-30 20:59:39 -05:00
Harrison Booth 8f7dff0d04 Merge main into function updates 2026-10-30 20:59:31 -05:00
Harrison Booth 7b7e734524 Merge main into function updates 2026-10-30 20:59:20 -05:00
Harrison Booth 6d5ab57f84 Merge upstream main into Weibull distribution branch 2026-10-30 20:59:18 -05:00
Harrison Booth a04629576d Merge main into function updates 2026-10-30 20:59:12 -05:00
Harrison Booth 3174020a70 Merge main into function updates 2026-10-30 20:59:02 -05:00
Harrison Booth 781149a593 Merge main into function updates 2026-10-30 20:58:53 -05:00
Harrison Booth 322298b202 Merge upstream main into gamma distribution branch 2026-10-30 20:58:48 -05:00
Harrison Booth 11d6ff1b79 Merge main into function updates 2026-10-30 20:58:38 -05:00
Harrison Booth eefa7c4a54 Merge main into function updates 2026-10-30 20:58:24 -05:00
Harrison Booth 45939a8a41 Merge remote-tracking branch 'upstream/main' into gc-build-g718x
# Conflicts:
#	docs/function-parity.md
2026-10-30 20:56:57 -05:00
Harrison Booth 70da8bb3ae Cover strict upper tail in F inverse 2026-10-30 20:53:15 -05:00
Harrison Booth a0da53f576 Match T DIST zero degree errors by mode 2026-10-30 20:53:00 -05:00
Harrison Booth ca44a5d68f Avoid overflow in t density normalization 2026-10-30 20:52:05 -05:00
Harrison Booth 3b61d660e9 Return NUM for invalid T distribution degrees 2026-10-30 20:51:47 -05:00
Harrison Booth cca8a58bfa Stabilize F distribution CDF at extreme degrees 2026-10-30 20:51:07 -05:00
Harrison Booth d3cd393dba G719: return NA for empty harmonic mean input 2026-10-30 20:25:09 -05:00
Harrison Booth 8985685833 G718: match beta density endpoint errors 2026-10-30 20:24:21 -05:00
Harrison Booth 962a5df841 G717: cap binomial trial counts 2026-10-30 20:23:32 -05:00
Harrison Booth fea4c03ca2 G709: use stable tail at F median 2026-10-30 20:18:56 -05:00
Harrison Booth 1d9096e5ec G709: stabilize F upper tail at extreme degrees 2026-10-30 20:05:47 -05:00
Harrison Booth 8e740d97e1 G707: stabilize decimal boundary cases 2026-10-30 19:58:17 -05:00
Harrison Booth a9bacdca4c G711: keep T.DIST validation isolated 2026-10-30 19:56:36 -05:00
Cédric Legrand 53d6de3c16 docs(releasing): M13: correct outdated prerequisites 2026-10-31 01:49:44 +01:00
Harrison Booth 6aa2cb8223 G709: invert upper F probabilities through complementary tail 2026-10-30 19:49:32 -05:00
Harrison Booth 83e6408b71 G707: align signed output boundary regression 2026-10-30 19:47:30 -05:00
Harrison Booth 1b1224c67a G713: simplify zero-shape boundary 2026-10-30 19:42:50 -05:00
Harrison Booth 213b76af10 Expand bit shift boundary regressions 2026-10-30 19:39:26 -05:00
Harrison Booth 4e5c86d842 G711: preserve small density term at huge degrees 2026-10-30 19:39:05 -05:00
Harrison Booth ad69d4cbdd G710: update zero-degree test error 2026-10-30 19:39:00 -05:00
Harrison Booth 32dbf6a224 G705: fix single-character case conversions 2026-10-30 19:37:03 -05:00
Harrison Booth 11b54ebe40 G708: retain dollar sign when rounding to zero 2026-10-30 19:36:23 -05:00
Harrison Booth 9e710199b1 Move base source parity note to history 2026-10-30 19:36:06 -05:00
Harrison Booth 5ee65a8005 Tighten UNICHAR regressions and parity history 2026-10-30 19:35:50 -05:00
Harrison Booth 48c6b57060 G714: return zero Weibull density at zero 2026-10-30 19:34:59 -05:00
Harrison Booth ec2efe3246 G713: correct gamma density at zero 2026-10-30 19:34:36 -05:00
Harrison Booth 5ed6a1b9c7 G707: enforce fixed and dollar output limits 2026-10-30 19:34:32 -05:00
Harrison Booth 5d89b9efdf Move bit shift parity note to revision history 2026-10-30 19:34:19 -05:00
Harrison Booth f1ff38504a G712: return division error for zero T inverse probability 2026-10-30 19:34:12 -05:00
Harrison Booth 0435ff50f9 G711: stabilize large-degree t density 2026-10-30 19:33:50 -05:00
Harrison Booth 550085f8b8 G710: return division error for zero T degrees 2026-10-30 19:33:10 -05:00
Harrison Booth 3a27743a38 Move places parity note to revision history 2026-10-30 19:32:48 -05:00
Harrison Booth dc691d52db G709: accept F distribution boundary degrees 2026-10-30 19:32:22 -05:00
Harrison Booth bd8cf4e14a G706: avoid binary noise in fixed decimal text 2026-10-30 19:32:19 -05:00
Harrison Booth dc75555ee9 G705: match Excel single-character casing 2026-10-30 19:30:32 -05:00
Harrison Booth 4d725e6bc1 Reject Unicode noncharacters in UNICHAR 2026-10-30 19:24:46 -05:00
Harrison Booth 9fccec28af Handle zero-valued oversized bit shifts 2026-10-30 19:24:22 -05:00
Harrison Booth 52d317c8ff Validate places for negative base conversions 2026-10-30 19:23:51 -05:00
Harrison Booth 00359ee93a Merge current main into G701 branch 2026-10-30 19:23:09 -05:00
Harrison Booth c895acf1f7 Fix base conversion whitespace validation 2026-10-30 19:22:39 -05:00
Dylan M. Taylor 1f4c67ef0a fix(flatpak): allow reading launcher version pins (#2985)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:32 -04:00
Michael HatzandClaude Opus 5.5 8cf8425745 Print checkbox test: check that the unchecked box is outlined
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:52:22 +01:00
NetterSilverandClaude Haiku 5.5 59a8b14d4d Print structured refs to columns starting with @ in double brackets
Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 22:26:49 +00:00
Michael HatzandClaude Opus 5.5 8f93ba2e9c Print checkbox cells as boxes, not TRUE/FALSE (#2972)
A checkbox cell drew a box on screen but printed and exported to PDF
its value as TRUE or FALSE. The print path now draws the box as the
grid does: white with a grey outline when unchecked, green with a
white tick when checked. Other values in a checkbox cell still print
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:14:20 +01:00
Harrison Booth 3de6cb1a3d gc-fix-2657: tighten PERCENTRANK boundary tolerance 2026-10-30 16:09:50 -05:00
Harrison Booth 75d4de230b Merge remote-tracking branch 'upstream/main' into gc-fix-functions
# Conflicts:
#	docs/function-parity.md
2026-10-30 16:03:03 -05:00
Harrison Booth fbff1e301c gc-fix-2657: isolate PERCENTRANK parity fix 2026-10-30 16:01:37 -05:00
Harrison Booth 5c31d7a17e gc-fix-2657: isolate MOD parity fix 2026-10-30 16:01:08 -05:00
Harrison Booth 22b6dfe8d7 gc-fix-2657: split YEARFRAC parity change 2026-10-30 16:00:26 -05:00
Harrison Booth b612141726 xlsx: keep intl date functions in xlfn list 2026-10-30 15:56:52 -05:00
Harrison Booth c82a138462 M2.1: fix XLS style test clippy lints 2026-10-30 15:47:18 -05:00
Harrison Booth 591c6ed6f9 Fix XLS style test Clippy complexity 2026-10-30 15:40:56 -05:00
Harrison Booth 9587f08253 Merge upstream main into gc-xls-cell-styles 2026-10-30 15:31:49 -05:00
Harrison Booth f6015a7e6e Pin COPILOT as written without an _xlfn. prefix 2026-10-27 21:21:04 -05:00
Harrison Booth 25816f0009 xlsx: write _xlfn. for STOCKHISTORY, TRANSLATE, DETECTLANGUAGE; none for NETWORKDAYS.INTL, WORKDAY.INTL 2026-10-27 20:57:17 -05:00
Harrison Booth 66f8b05fc2 Merge upstream main into gc-fix-functions
# Conflicts:
#	crates/functions/src/math.rs
#	crates/functions/src/stat.rs
2026-10-27 16:17:07 -05:00
Harrison Booth 0e643e9026 Merge remote-tracking branch 'upstream/main' into gc-xls-cell-styles 2026-10-27 15:59:33 -05:00
Harrison Booth c72f5a49b0 PERCENTRANK test: check the fifteenth digit with a tight tolerance 2026-10-26 11:48:54 -05:00
Harrison Booth 5ed5c09bc6 NUMBERVALUE: a leftover dot is a decimal point, not a date separator 2026-10-26 11:39:22 -05:00
Harrison Booth a75b19e678 MOD test: use the literal Excel reads (15 significant digits) 2026-10-26 11:32:08 -05:00
Harrison Booth 551ec6590d Fix literal grouping in the MOD test 2026-10-26 11:25:20 -05:00
Harrison Booth 9cc126409b Match Excel for YEARFRAC basis 0, MOD, NUMBERVALUE and PERCENTRANK edge cases
Checked against Excel 16: YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD gives #NUM! once the quotient magnitude reaches 1.1259E12; NUMBERVALUE no longer rejects unmatched separators; PERCENTRANK gives #DIV/0! from significance 309 and 15 significant digits from significance 15.
2026-10-26 11:15:00 -05:00
Harrison Booth 9ed1001035 Merge remote-tracking branch 'origin/main' into gc-xls-cell-styles
# Conflicts:
#	ROADMAP.md
2026-10-24 15:32:17 -05:00
Harrison Booth bf85ba482c Align .xls estimate in gaps with file-format parity 2026-10-24 15:10:40 -05:00
Harrison Booth ad2e3ac9de Check duplicate .xls style names in linear time 2026-10-23 16:37:19 -05:00
Harrison Booth 2c052d03d5 Read cell formatting from .xls files
.xls (Excel 97-2003) import used only each cell's number format. It now
also reads the BIFF8 formatting records (Font, XF, Palette and Style),
so opened sheets keep their fonts, solid and pattern fills, borders
(including diagonals), alignment (rotation, indent, wrap, shrink),
locked and hidden protection, the workbook's custom colour palette, its
Normal font as the default, and its user-defined cell styles.

The decoding lives in a new xls/styles.rs; xls/mod.rs gains the record
arms and the build call. Short or malformed formatting records keep the
fields present and add one warning instead of refusing the workbook.
Tests build the records in code, including truncation and byte mutation.

Also merges origin/main, keeping its dialog-sheet, WINDOW2 and XF
number-format handling; the XF arm now goes through the new styles
module, which covers the number format too.
2026-10-23 16:32:15 -05:00
128 changed files with 4705 additions and 517 deletions
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
+5 -10
View File
@@ -2,8 +2,6 @@
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
## Never crash
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
@@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## Contributor credits (About window)
@@ -62,14 +60,11 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+9 -9
View File
@@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `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 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | |
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
+4 -6
View File
@@ -2,8 +2,6 @@
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
## Never crash
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
@@ -50,7 +48,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
Generated
+31 -30
View File
@@ -220,7 +220,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -889,12 +889,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -917,9 +917,10 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree",
"tempfile",
@@ -930,7 +931,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -950,7 +951,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -976,7 +977,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -1456,7 +1457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1936,7 +1937,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1963,7 +1964,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1975,7 +1976,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1986,7 +1987,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1998,7 +1999,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"chrono",
"js-sys",
@@ -2009,7 +2010,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -2031,7 +2032,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -2040,7 +2041,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -2048,7 +2049,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -2057,7 +2058,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2069,18 +2070,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.13.0"
version = "0.15.0"
[[package]]
name = "gridcraft-ui-egui"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -2105,7 +2106,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -2122,7 +2123,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"aes",
"gridcraft-core",
@@ -3461,7 +3462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3875,7 +3876,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4185,7 +4186,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4244,7 +4245,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4978,7 +4979,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5937,7 +5938,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6403,7 +6404,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.13.0"
version = "0.15.0"
dependencies = [
"gridcraft-engine",
"serde_json",
+3 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.13.0"
version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -27,7 +27,7 @@ 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: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -63,7 +63,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+9 -1
View File
@@ -298,7 +298,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+14 -3
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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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,11 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
@@ -167,6 +172,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
@@ -233,7 +239,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
@@ -244,6 +250,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
| 2026-10-31 | minor | Progress log: Function Library |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
@@ -262,6 +272,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
@@ -296,5 +307,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` |
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` |
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
+5 -1
View File
@@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
@@ -52,6 +52,10 @@ muda = { version = "0.21", default-features = false }
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda,
# winit); pinned exactly to the same versions.
objc2 = "=0.6.5"
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
# pinned exactly to the same versions.
+28 -1
View File
@@ -11,6 +11,8 @@
use fmv_macos_events::{Event, Inbox, Registration};
use gridcraft_ui_egui::SheetApp;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSRequestUserAttentionType};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
@@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
}
}
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
Event::Quit => app.request_quit(ctx),
Event::Quit => {
app.request_quit(ctx);
if app.dialog.is_some() {
bring_app_forward();
}
}
}
}
}
/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to
/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and
/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock
/// icon until the user switches over.
fn bring_app_forward() {
let Some(mtm) = MainThreadMarker::new() else { return };
let ns_app = NSApplication::sharedApplication(mtm);
ns_app.unhide(None);
for window in ns_app.windows().to_vec() {
if window.isMiniaturized() {
window.deminiaturize(None);
}
}
#[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version.
ns_app.activateIgnoringOtherApps(true);
if !ns_app.isActive() {
ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest);
}
}
+78 -2
View File
@@ -1,4 +1,5 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
//! Graphics instance configuration, applied before wgpu can initialize any driver: Windows
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
//!
//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters
//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access
@@ -8,12 +9,38 @@
//! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see
//! [`dx12_compiler`].
// On macOS only the indirect-validation items are used outside tests; on Windows the other way
// round, so those two carry their own `allow`.
#![cfg_attr(target_os = "macos", allow(dead_code))]
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType, Dx12Compiler},
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
};
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On Intel
/// Macs (Iris 5100 in a Mac mini 2014) Metal's compiler aborts on them ("Compiler encountered an
/// internal error"), the device is lost and eframe exits before the window opens. GridCraft issues
/// no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's default.
/// Same as WordCraft (storytold/wordcraft#2652) and PhotoCraft (storytold/PhotoCraft#3171).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
if !skip {
return;
}
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
}
/// What the start-up probe learned about one DirectX 12 adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Dx12Adapter {
@@ -141,6 +168,55 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
mod tests {
use super::*;
fn instance_flags(options: &NativeOptions) -> InstanceFlags {
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.flags
}
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
}
let mut options = NativeOptions::default();
let before = instance_flags(&options);
configure_indirect_validation(&mut options, false);
assert_eq!(instance_flags(&options), before, "flags untouched when not skipping");
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
configure_indirect_validation(&mut options, true);
assert!(!instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the validation back on even when it is skipped.
/// Tests can't set environment variables (`set_var` is unsafe, and unsafe code is forbidden),
/// so this runs itself again as a child process with the variable set.
#[test]
fn the_environment_turns_indirect_validation_back_on() {
const NAME: &str = "graphics::tests::the_environment_turns_indirect_validation_back_on";
if std::env::var_os("GRIDCRAFT_INDIRECT_VALIDATION_CHILD").is_some() {
let mut options = NativeOptions::default();
configure_indirect_validation(&mut options, true);
assert!(instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
return;
}
let exe = std::env::current_exe().unwrap();
let out = std::process::Command::new(exe)
.args(["--exact", NAME, "--test-threads=1"])
.env("GRIDCRAFT_INDIRECT_VALIDATION_CHILD", "1")
.env("WGPU_VALIDATION_INDIRECT_CALL", "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
// `--exact` matching nothing would pass too, so the child must report this one test.
assert!(out.status.success() && stdout.contains("1 passed"), "child test failed:\n{stdout}{}", String::from_utf8_lossy(&out.stderr));
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+5 -2
View File
@@ -12,7 +12,7 @@
#[cfg(target_os = "macos")]
mod apple_events;
mod control_server;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "macos", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
@@ -341,8 +341,11 @@ fn main() -> eframe::Result<()> {
}
let window_builder = viewport.clone();
let options = eframe::NativeOptions { viewport, ..Default::default() };
#[cfg(all(unix, not(target_os = "macos")))]
#[cfg(unix)]
let mut options = options;
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (see graphics.rs).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
// 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.
+1 -1
View File
@@ -1,7 +1,7 @@
# GridCraft app icon (PLACEHOLDER)
**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the
product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet.
product owner makes; GridCraft's hasn't been made yet.
Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number
column, a selected cell with its fill handle and a double rule under a total, on a full-bleed
GridCraft-green tile. It is original geometry written by hand for this repository, deliberately
+1 -1
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
Original work; temporary until the owner's engraved creature portrait replaces it
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
Palette: ink #0b0b0c, paper #efe9dc,
app colour #1f9d55 (proposed). -->
<defs>
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
Original work; temporary until the owner's engraved creature portrait replaces it
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
Palette: ink #0b0b0c, paper #efe9dc,
app colour #1f9d55 (proposed). -->
<defs>
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+5 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -109,3 +106,6 @@ sync_github_name = true
[people.Plutonium-fream]
sync_github_name = true
[people.maxm11]
sync_github_name = true
+29 -2
View File
@@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above
/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap.
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?;
if cells > MAX_CELLS {
return None;
}
usize::try_from(cells).ok()
}
fn broadcast_dim(a: usize, b: usize) -> usize {
@@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
pub(super) fn single(v: Value) -> Option<Value> {
match v {
Value::Array(a) if a.rows * a.cols > 1 => None,
Value::Array(a) if a.data.len() > 1 => None,
v => Some(v.scalar()),
}
}
@@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
}
}
#[cfg(test)]
mod tests {
use super::{MAX_CELLS, broadcast_len};
#[test]
fn broadcast_len_caps_at_max_cells() {
assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize));
assert_eq!(broadcast_len(4_000, 4_001), None);
assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize));
}
/// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a
/// `> MAX_CELLS` test would let through; the guard must reject the true product.
#[test]
fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() {
assert_eq!(65_536u32.wrapping_mul(65_536), 0);
assert_eq!(broadcast_len(65_536, 65_536), None);
assert_eq!(broadcast_len(1_048_576, 16_384), None);
}
}
+19
View File
@@ -1251,6 +1251,25 @@ fn array_broadcast_result_is_capped() {
assert_eq!(t.num("C3"), 5.0);
}
/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the
/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in
/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at
/// 16384 columns, so the widest product it can build is 16384 squared).
#[test]
fn map_caps_its_result_size() {
let mut t = T::new();
for (at, formula) in
[("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")]
{
t.set(at, formula);
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
}
// Within the cap the broadcast shape is unchanged.
t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0));
}
#[test]
fn text_function() {
let mut t = T::new();
+5 -2
View File
@@ -20,7 +20,7 @@ mod paste_special;
mod selection;
use cell::{cell_set, range_fill, range_set_values};
pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula};
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
#[cfg(test)]
pub(crate) use clipboard::parse_tsv;
use clipboard::{clear_clipboard, copy, cut, paste};
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
has_doc,
range_set_values
),
@@ -183,6 +183,8 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
// Step by step: a rename and a new sheet undone together match up only one step apart.
super::extra::keep_view_modes(d, &cur_wb);
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
@@ -203,6 +205,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
super::extra::keep_view_modes(d, &cur_wb);
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
+67 -12
View File
@@ -5,6 +5,16 @@ use super::{
// ---------------------------------------------------------------- entry
/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or
/// a parenthesis (not a number), as Excel takes `+SUM(1)`.
pub(crate) fn is_formula_input(input: &str) -> bool {
input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('))
}
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
/// format). Inherits the destination's cell, row or column style, including its number
/// format, before parsing. Errors when the formula can't be parsed.
@@ -18,12 +28,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
if fmt_is_text {
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
}
let is_formula = input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
if is_formula_input(input) {
let body = input.strip_prefix('=').unwrap_or(input);
let expr = wb.name_call_case(parse_typed_formula(body)?);
check_arg_counts(&expr)?;
@@ -183,6 +188,22 @@ pub(crate) fn json_to_input(v: &Json) -> String {
}
}
/// How many converted strings `range.setValues` reports back, and how much of each input and
/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data
/// in the result.
const MAX_REPORTED_CONVERSIONS: usize = 20;
const MAX_REPORTED_CHARS: usize = 32;
/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was
/// cut. Counting characters keeps a multi-byte input whole.
fn clipped(text: &str) -> String {
let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect();
if out.len() < text.len() {
out.push('…');
}
out
}
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
@@ -200,6 +221,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("range.setValues", "too many rows"));
}
let mut count = 0;
// The strings typed input read as something other than the text given, for the result: a
// caller can notice a "05.04.2026" that became a date without reading the file back (#2255).
let mut converted: Vec<(CellRef, String)> = Vec::new();
let mut converted_count = 0;
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
let cols: Vec<&Json> = match row.as_array() {
@@ -222,15 +247,35 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
if text.is_empty() {
// An empty string clears the cell, as `null` and the default path do:
// an empty text cell is not blank, and a script clearing cells with
// `""` would leave cells `COUNTA` counts. The cell keeps its formatting.
input_to_cell("", sheet, at, &mut cx.wb)?
} else {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
// A string that became a formula was written as one (`=`), which is deliberate;
// only a number (dates and times included), boolean or error is a reading of the
// string and worth reporting.
if let Some(c) = &cell
&& matches!(v, Json::String(_))
&& c.formula.is_none()
&& matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_))
{
converted_count += 1;
if converted.len() < MAX_REPORTED_CONVERSIONS {
converted.push((at, input.clone()));
}
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
@@ -245,7 +290,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
}
Ok(())
})?;
Ok(json!({"cells": count}))
let d = s.doc()?;
let reported: Vec<Json> =
d.wb.sheet(sheet)
.map(|sh| {
converted
.iter()
.map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))}))
.collect()
})
.unwrap_or_default();
Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported}))
}
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
+68
View File
@@ -192,6 +192,74 @@ fn set_values_can_store_strings_as_the_text_they_are() {
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
/// The result names the strings typed input read as something else, so a script notices without
/// reading the file back (#2255). A formula and a leading apostrophe are not conversions.
#[test]
fn set_values_reports_the_strings_typed_input_read() {
let mut s = Session::new();
s.new_workbook();
let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]);
let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(r["cells"], 6, "{r}");
assert_eq!(r["convertedCount"], 4, "{r}");
assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}");
assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}");
assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}");
assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}");
// The report is a diagnostic, not a second copy of the data: a long input is cut.
let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap();
assert_eq!(long["convertedCount"], 1, "{long}");
let input = long["converted"][0]["input"].as_str().unwrap_or_default();
assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}");
assert!(input.ends_with('…'), "{input}");
// Nothing is read with `parse: false`, so nothing is reported.
let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap();
assert_eq!(verbatim["convertedCount"], 0, "{verbatim}");
assert_eq!(verbatim["converted"], json!([]), "{verbatim}");
}
/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a
/// script clearing cells with "" must not leave cells `COUNTA` counts.
#[test]
fn set_values_parse_false_clears_an_empty_string() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap();
s.execute("home.bold", json!({"range": "A1"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["type"], "empty");
assert_eq!(get(&mut s, "B1")["type"], "empty");
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting");
assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0));
}
/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the
/// default's reading of a date survives as a date (#2255).
#[test]
fn set_values_text_survives_an_xlsx_roundtrip() {
let mut s = Session::new();
s.new_workbook();
let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]);
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap();
s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap();
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
let sh = wb.sheet(0).unwrap();
for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] {
assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given");
}
assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula");
assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0));
assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true));
// The default read the date, and the file keeps it as one.
let at = CellRef::new(2, 0);
assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026");
assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy");
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+2 -2
View File
@@ -28,7 +28,7 @@ use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}
pub(crate) use scripts::has_scripts;
use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts};
use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window};
pub(crate) use window::{keep_watch_list, window_param};
pub(crate) use window::{keep_view_modes, keep_watch_list, window_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -310,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
new_cell_style
),
cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))),
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used),
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used),
cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))),
cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))),
cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))),
+3 -18
View File
@@ -122,25 +122,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
/// ([`crate::Prefs::recent_functions`]).
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
let mut seen: Vec<String> = Vec::new();
for (id, p) in s.journal.iter().rev() {
if id != "cell.set" {
continue;
}
let input = p.get("input").and_then(Json::as_str).unwrap_or("");
if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
for f in e.functions() {
if !seen.iter().any(|x| x == f) {
seen.push(f.to_string());
}
}
}
if seen.len() >= 10 {
break;
}
}
Ok(json!(seen))
Ok(json!(s.prefs.recent_functions))
}
pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
+29
View File
@@ -38,6 +38,35 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
wb.names.extend(keep);
}
/// Normal, Page Layout and Page Break Preview belong to the window, not to the edits: Excel doesn't
/// undo a switch between them, and `set_view` is no undo step. So undo and redo keep each sheet's
/// current view when they swap in another snapshot of the workbook, and a view kept this way
/// doesn't make a saved workbook dirty.
pub(crate) fn keep_view_modes(d: &mut crate::DocState, now: &Workbook) {
// Sheets have no stable id. A name missing from `now` is a rename being undone or redone, which
// keeps the sheet count and position: the sheet is the one at the same index, under a name the
// restored snapshot doesn't have.
let renamed = |i: usize| {
(now.sheets.len() == d.wb.sheets.len()).then(|| now.sheets.get(i)).flatten().filter(|sh| !d.wb.sheets.iter().any(|s| s.name == sh.name))
};
let current = |i: usize, name: &str| now.sheets.iter().find(|sh| sh.name == name).or_else(|| renamed(i)).map(|sh| sh.view);
let changed: Vec<(usize, SheetViewMode)> =
d.wb.sheets.iter().enumerate().filter_map(|(i, sh)| current(i, &sh.name).filter(|v| *v != sh.view).map(|v| (i, v))).collect();
if changed.is_empty() {
return;
}
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
for (i, v) in changed {
if let Some(sh) = wb.sheet_mut(i) {
sh.view = v;
}
}
if !dirty {
d.saved = d.wb.clone();
}
}
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_map(&d.wb, VIEWS_NAME);
+36 -2
View File
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
Workbook, split_sheet,
};
// ---------------------------------------------------------------- drawing
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|| merges.iter().any(|m| m.start == *c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
}
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
draw_checkbox(pg, (x, y, w, h), b.k, on);
} else {
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
}
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
pg.restore();
}
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
/// print as text.
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
return None;
}
match v {
Value::Bool(on) => Some(*on),
Value::Empty => Some(false),
_ => None,
}
}
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
if s <= 0.0 {
return;
}
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
if on {
let green = [0x10, 0x7C, 0x41];
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
} else {
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
}
}
pub(super) struct PendingPdfLink {
pub(super) page: usize,
pub(super) sheet: usize,
+16
View File
@@ -565,3 +565,19 @@ fn rotated_wrapped_text_prints_rotated() {
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
}
#[test]
fn checkbox_cells_print_as_boxes_not_true_false() {
// #2972: a checkbox cell printed its value as TRUE or FALSE.
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
}
+6 -5
View File
@@ -153,7 +153,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
// Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| {
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
_ => format!("We can't open '{name}': {e}"),
@@ -182,17 +183,17 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
Ok((wb, report.warnings))
}
_ => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
Ok((wb, report.warnings))
}
}
}
+43
View File
@@ -291,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() {
assert_eq!(wb.sheets[0].name, "Sales");
}
#[test]
fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() {
let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap();
let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap();
for file in ["dates.csv", "dates.txt"] {
let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}");
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}");
assert!(warnings.is_empty(), "{file}: {warnings:?}");
}
let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap();
assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
@@ -561,3 +575,32 @@ fn content_wins_over_a_mismatched_extension() {
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
}
}
/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)`
/// wrapper as the formula inside when GridCraft calculates every function it calls (including
/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a
/// Google-only function.
#[test]
fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
use gridcraft_core::{CellRef, Value};
let mut wb = Workbook::new();
let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let wrapped = [
("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)),
("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")),
];
for (at, text, value) in &wrapped {
let cell = gridcraft_model::Cell {
value: value.clone(),
formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))),
..Default::default()
};
sh.cells.set(CellRef::parse(at).unwrap(), cell);
}
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap();
let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))"));
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
}
+18
View File
@@ -20,6 +20,7 @@ pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
pub mod recent;
pub mod record;
pub mod sample;
pub mod selection;
@@ -390,6 +391,10 @@ pub struct Prefs {
pub formula_bar: bool,
/// The statistics the status bar shows for a selection (right-click the status bar).
pub status_bar: StatusBarStats,
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
/// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel.
#[serde(deserialize_with = "recent::lenient")]
pub recent_functions: Vec<String>,
}
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
@@ -424,6 +429,7 @@ impl Default for Prefs {
user_dictionary: vec![],
formula_bar: true,
status_bar: StatusBarStats::default(),
recent_functions: recent::defaults(),
}
}
}
@@ -558,6 +564,17 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit
/// is allowed. Front ends ask before opening an editor, so typing into a locked cell never
/// leaves a draft that can't be entered (and so blocks saving).
pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option<String> {
let d = self.active()?;
let sh = d.wb.sheet(sheet)?;
if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked {
return None;
}
cmd::locked_cell_error(sh, at, &d.unlocked_ranges)
}
/// 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;
@@ -790,6 +807,7 @@ impl Session {
}
if self.journal_paused == 0 {
self.push_journal(id, cmd::journal_params(&params));
recent::note(&mut self.prefs.recent_functions, id, &params);
}
}
// Inside an undo group the group owns the step. A command that ended a group open before
+205
View File
@@ -0,0 +1,205 @@
//! Formulas › Recently Used: the functions most recently entered in a formula.
//!
//! The list is [`crate::Prefs::recent_functions`], an application list like Excel's, saved with
//! the other preferences. [`crate::Session::execute`] calls [`note`] for each `cell.*` and
//! `range.*` command it journals, from any source (the interface, the control channel, MCP or a
//! script), so the list never depends on the journal, which drops its oldest entries.
use std::collections::HashSet;
use std::sync::OnceLock;
use crate::cmd::edit::is_formula_input;
use gridcraft_formula::lexer::{Tok, tokenize};
use gridcraft_formula::normalize_function_name;
use serde::Deserialize;
use serde_json::Value as Json;
/// How many functions the list keeps.
pub const MAX: usize = 10;
/// The longest formula read (Excel's formula length limit): a longer one was not typed.
const FORMULA_MAX: usize = 8_192;
/// The most formulas read from one command: a large paste needs only its last few.
const SCAN_BUDGET: usize = 2_000;
/// What the list holds before any function has been used.
pub fn defaults() -> Vec<String> {
["SUM", "AVERAGE", "IF", "COUNT", "MAX", "MIN", "SUMIF", "XLOOKUP", "PMT", "TODAY"].map(String::from).to_vec()
}
/// Whether `name` (upper case, without `_xlfn.`) is a function of the catalog.
fn known(name: &str) -> bool {
static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
NAMES
.get_or_init(|| crate::cmd::formulas::function_list().iter().filter_map(|f| f["name"].as_str().map(str::to_ascii_uppercase)).collect())
.contains(name)
}
/// The functions a formula calls, outermost first, known functions only. A formula is what a
/// cell takes as one (`=…`, or `+…`/`-…` as Excel takes `+SUM(1)`).
pub fn functions_in_formula(text: &str) -> Vec<String> {
if !is_formula_input(text) {
return vec![];
}
let body = text.strip_prefix('=').unwrap_or(text);
if body.len() > FORMULA_MAX {
return vec![];
}
let mut out: Vec<String> = Vec::new();
for t in tokenize(body).unwrap_or_default() {
if let Tok::Func(name) = t.tok {
let name = normalize_function_name(&name);
if known(&name) && !out.contains(&name) {
out.push(name);
}
}
}
out
}
/// The formula texts a command's params enter, the last first, at most `budget` of them.
fn formulas_newest_first<'a>(id: &str, params: &'a Json, budget: usize) -> Vec<&'a str> {
fn walk<'a>(v: &'a Json, out: &mut Vec<&'a str>, depth: usize, budget: usize) {
match v {
Json::String(s) if is_formula_input(s) && out.len() < budget => out.push(s),
// A range's values are rows of cells; anything deeper is not a cell value.
Json::Array(a) if depth < 2 => {
for x in a.iter().rev() {
if out.len() >= budget {
return;
}
walk(x, out, depth + 1, budget);
}
}
_ => {}
}
}
let mut out = Vec::new();
if id.starts_with("cell.") || id.starts_with("range.") {
for key in ["formula", "values", "value", "input"] {
if let Some(v) = params.get(key) {
walk(v, &mut out, 0, budget);
}
}
}
out
}
/// Moves `names` (most recent first) to the front of `recent`, keeping at most [`MAX`].
pub fn push(recent: &mut Vec<String>, names: &[String]) {
let mut next: Vec<String> = Vec::with_capacity(MAX);
for n in names.iter().chain(recent.iter()) {
if next.len() == MAX {
break;
}
if !next.contains(n) {
next.push(n.clone());
}
}
*recent = next;
}
/// Moves the functions that the formulas a command entered call to the front of `recent`. The
/// last formulas are read first (they are the most recent), within [`SCAN_BUDGET`].
pub(crate) fn note(recent: &mut Vec<String>, id: &str, params: &Json) {
let mut names: Vec<String> = Vec::new();
for f in formulas_newest_first(id, params, SCAN_BUDGET) {
for n in functions_in_formula(f) {
if !names.contains(&n) {
names.push(n);
}
}
if names.len() >= MAX {
break;
}
}
if !names.is_empty() {
push(recent, &names);
}
}
/// Reads a saved list leniently: known function names only, at most [`MAX`]. A value that is not
/// a list gives the default list, so a hand-edited or hostile `prefs.json` loses this list, never
/// the other preferences.
pub(crate) fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
let raw = Json::deserialize(d)?;
let Some(list) = raw.as_array() else { return Ok(defaults()) };
let mut out = Vec::new();
for name in list.iter().filter_map(Json::as_str) {
if out.len() == MAX {
break;
}
let name = name.to_ascii_uppercase();
if known(&name) && !out.contains(&name) {
out.push(name);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn formulas_name_their_functions_outermost_first() {
assert_eq!(functions_in_formula("=IF(SUM(A1:A3)>2,_xlfn.XLOOKUP(1,B:B,C:C),sum(1))"), ["IF", "SUM", "XLOOKUP"]);
assert!(functions_in_formula("=MYLAMBDA(1)+A1").is_empty(), "only known functions");
assert!(functions_in_formula("SUM(1)").is_empty(), "not a formula");
assert!(functions_in_formula("=\"SUM(\"").is_empty(), "inside a string");
assert!(functions_in_formula(&format!("=SUM({})", "1+".repeat(9000))).is_empty(), "too long");
assert!(functions_in_formula("=((((").is_empty());
assert!(functions_in_formula("=").is_empty());
// A formula may be typed with + or - instead of =, as input_to_cell takes it.
assert_eq!(functions_in_formula("+SUM(1)"), ["SUM"]);
assert_eq!(functions_in_formula("-ABS(A1)"), ["ABS"]);
assert!(functions_in_formula("-5").is_empty() && functions_in_formula("+1e3").is_empty(), "numbers");
assert!(functions_in_formula("+").is_empty() && functions_in_formula("- SUM(1)").is_empty(), "not formulas");
}
#[test]
fn commands_give_their_formulas_newest_first() {
let read = |id: &str, params: Json| formulas_newest_first(id, &params, SCAN_BUDGET).into_iter().map(String::from).collect::<Vec<_>>();
assert_eq!(read("range.setValues", json!({"values": [["=SUM(1)", 2], ["x", "=PMT(1,2,3)"]]})), ["=PMT(1,2,3)", "=SUM(1)"]);
assert_eq!(read("cell.set", json!({"input": "=NPV(1,2)"})), ["=NPV(1,2)"]);
assert_eq!(read("range.setValues", json!({"values": [["+SUM(1)", "-5", "-ABS(2)"]]})), ["-ABS(2)", "+SUM(1)"]);
assert!(read("home.bold", json!({"input": "=IRR(1)"})).is_empty(), "only cell.* and range.* enter formulas");
assert!(read("range.setValues", json!({"values": [[["=FV(1,2,3)"]]]})).is_empty(), "deeper than rows of cells");
// A large paste is read from its end, within the budget.
let rows: Vec<Json> = (0..5_000).map(|i| json!([format!("=ABS({i})")])).collect();
let paste = json!({ "values": rows });
let last = formulas_newest_first("range.setValues", &paste, 3);
assert_eq!(last, ["=ABS(4999)", "=ABS(4998)", "=ABS(4997)"]);
}
#[test]
fn recent_keeps_ten_distinct_newest_first() {
let mut r = defaults();
assert!(r.iter().all(|n| known(n)), "the default list names known functions");
push(&mut r, &["PMT".into(), "NPV".into()]);
assert_eq!(r.len(), MAX);
assert_eq!(&r[..3], ["PMT", "NPV", "SUM"]);
assert_eq!(r.iter().filter(|n| *n == "PMT").count(), 1);
let many: Vec<String> =
["ABS", "ACOS", "AND", "CHOOSE", "DATE", "EXP", "FIND", "INDEX", "LEFT", "LN", "MOD", "NOW"].map(String::from).to_vec();
push(&mut r, &many);
assert_eq!(r, many[..MAX]);
}
#[test]
fn a_saved_list_is_read_leniently() {
let prefs = |v: Json| serde_json::from_value::<crate::Prefs>(v).unwrap();
assert_eq!(prefs(json!({"r1c1": true})).recent_functions, defaults(), "a prefs.json from before the list gets the default");
// A hostile value loses only itself, never the other preferences.
let bad = prefs(json!({"r1c1": true, "recentFunctions": 5}));
assert!(bad.r1c1);
assert_eq!(bad.recent_functions, defaults());
let mut odd: Vec<Json> = ["npv", "NOT_A_FUNCTION", "SUM", "NPV"].iter().map(|n| json!(n)).chain([json!(3), json!(null)]).collect();
odd.extend(crate::cmd::formulas::function_list().into_iter().take(40).map(|f| f["name"].clone()));
let odd = prefs(json!({ "recentFunctions": odd }));
assert_eq!(&odd.recent_functions[..2], ["NPV", "SUM"]);
assert_eq!(odd.recent_functions.len(), MAX);
let back = prefs(serde_json::to_value(&odd).unwrap());
assert_eq!(back.recent_functions, odd.recent_functions, "the list survives a save and reload");
}
}
+110
View File
@@ -1400,6 +1400,17 @@ fn protection_refuses_edits_of_locked_cells() {
assert_eq!(v(&s, "A1"), Value::Number(1.0));
}
#[test]
fn locked_cell_message_matches_what_an_edit_would_be_refused_with() {
let s = protected(json!({}));
let locked = CellRef::parse("A1").unwrap();
let unlocked = CellRef::parse("D1").unwrap();
assert!(s.locked_cell_message(0, locked).is_some_and(|m| m.contains("protected sheet")));
assert_eq!(s.locked_cell_message(0, unlocked), None, "an unlocked cell stays editable");
assert_eq!(s.locked_cell_message(9, locked), None, "no such sheet");
}
#[test]
fn protection_allows_permitted_row_and_column_edits() {
let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true}));
@@ -2403,6 +2414,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() {
}
}
#[test]
fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() {
let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 };
for (initial_active, command, params, expected_order, expected_active) in [
("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1),
("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1),
("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0),
("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0),
("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2),
] {
let mut session = s();
session.execute("sheet.rename", json!({"name": "First"})).unwrap();
for name in ["Second", "Third"] {
session.execute("home.insertSheet", json!({"name": name})).unwrap();
}
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view;
session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap();
session.execute(command, params).unwrap();
let wb = &session.doc().unwrap().wb;
assert_eq!(wb.active_sheet(), expected_active, "{command}");
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command}");
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command} after reopening");
assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}");
assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index");
assert_eq!(back.view, view, "workbook display settings survive {command}");
}
}
#[test]
fn xlsx_roundtrip_through_engine() {
let mut s = s();
@@ -5234,3 +5275,72 @@ fn unique_nonce() -> u64 {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// Normal, Page Layout and Page Break Preview are not undo steps (as in Excel), so undoing or
/// redoing an edit keeps each sheet in the view it is in now, instead of putting back the view
/// of the snapshot it swaps in. Found through the command-sequence fuzz (seed 177).
#[test]
fn undo_and_redo_keep_each_sheets_view() {
use gridcraft_model::SheetViewMode;
let view = |s: &Session, i: usize| s.doc().unwrap().wb.sheet(i).unwrap().view;
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("view.pageBreakPreview", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1"), Value::Empty);
assert_eq!(view(&s, 0), SheetViewMode::PageBreakPreview, "undo leaves the view alone");
s.execute("view.pageLayout", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(1.0));
assert_eq!(view(&s, 0), SheetViewMode::PageLayout, "redo leaves the view alone");
// Undoing a rename: the sheet is found at its position under the restored name.
s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
s.execute("view.normal", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_ne!(s.doc().unwrap().wb.sheet(0).unwrap().name, "Renamed");
assert_eq!(view(&s, 0), SheetViewMode::Normal);
// Undoing a new sheet: the other sheets keep theirs.
s.execute("view.pageBreakPreview", json!({})).unwrap();
s.execute("home.insertSheet", json!({"name": "Extra"})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 1);
assert_eq!(view(&s, 0), SheetViewMode::PageBreakPreview);
}
#[test]
fn a_multi_step_undo_keeps_views_through_each_step() {
// A rename and a new sheet undone in one call: neither the name nor the sheet count matches
// between the first and last workbook, so the view is carried step by step.
let mut s = s();
let first = s.doc().unwrap().wb.sheet(0).unwrap().name.clone();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
s.execute("view.pageBreakPreview", json!({"sheet": 0})).unwrap();
s.execute("home.insertSheet", json!({"name": "Extra"})).unwrap();
s.execute("edit.undo", json!({"steps": 2})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 1);
assert_eq!(d.wb.sheet(0).unwrap().name, first);
assert_eq!(d.wb.sheet(0).unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
s.execute("edit.redo", json!({"steps": 2})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.sheets.len(), d.wb.sheet(0).unwrap().name.as_str()), (2, "Renamed"));
assert_eq!(d.wb.sheet(0).unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
}
#[test]
fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
{
let d = s.doc_mut().unwrap();
d.saved = d.wb.clone();
}
s.execute("edit.undo", json!({})).unwrap();
s.execute("view.pageBreakPreview", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change");
}
@@ -34,7 +34,7 @@ use std::sync::Arc;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
use gridcraft_engine::calc::Calc;
use gridcraft_engine::model::{CalcMode, Formula, Sheet, Workbook};
use gridcraft_engine::model::{CalcMode, Formula, Sheet, SheetViewMode, Workbook};
use gridcraft_engine::{EngineError, Session, command_specs, find_command, io};
use serde_json::{Value as Json, json};
@@ -486,8 +486,9 @@ fn watch_list(wb: &Workbook) -> Option<&str> {
wb.names.iter().find(|n| n.name == WATCH_NAME).map(|n| n.formula.as_str())
}
/// The workbook with view state (active sheet, scroll, active cell) and the Watch Window list
/// reset: undo restores view state as it was, but switching sheets isn't an undoable edit.
/// The workbook with view state (active sheet, scroll, active cell, Normal / Page Layout / Page
/// Break Preview) and the Watch Window list reset: undo restores view state as it was, but
/// switching sheets or views isn't an undoable edit.
fn canonical(wb: &Workbook) -> Workbook {
let mut wb = wb.clone();
wb.set_active_sheet(0);
@@ -496,6 +497,7 @@ fn canonical(wb: &Workbook) -> Workbook {
let sh = Arc::make_mut(sh);
sh.view_active = CellRef::default();
sh.view_top_left = CellRef::default();
sh.view = SheetViewMode::Normal;
}
wb
}
@@ -1089,3 +1091,13 @@ fn params_docs_parse_into_keys() {
assert!(find_command(id).is_some(), "CURATED names unknown command `{id}`");
}
}
/// Seed 177: switching a sheet to Page Break Preview isn't an undo step (as in Excel), so undoing
/// everything leaves the view mode alone and the undo check must not count it as an edit.
#[test]
fn view_mode_switch_is_not_an_undo_difference() {
for id in ["view.pageBreakPreview", "view.pageLayout"] {
let failure = replay("sales", &[(id.to_string(), json!({"sheet": 0}))]);
assert!(failure.is_none(), "`{id}`: {failure:?}");
}
}
+1
View File
@@ -22,6 +22,7 @@ mod percent_entry;
mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod recently_used;
mod row_autofit;
mod sheet_copy_spill;
mod sheet_name_quoting;
+58
View File
@@ -0,0 +1,58 @@
//! Formulas › Recently Used: the functions most recently entered in a formula, from any command
//! that enters one, kept in the preferences rather than read back from the journal.
use gridcraft_engine::Session;
use serde_json::{Value as Json, json};
fn recent(s: &mut Session) -> Vec<String> {
let list = s.execute("formulas.recentlyUsed", json!({})).unwrap();
list.as_array().unwrap().iter().filter_map(Json::as_str).map(String::from).collect()
}
#[test]
fn formulas_entered_by_any_command_lead_the_list() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap();
s.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "PMT is just text"})).unwrap();
assert_eq!(&recent(&mut s)[..4], ["FV", "IRR", "ROUND", "NPV"]);
// A command that fails enters nothing.
assert!(s.execute("cell.set", json!({"cell": "not a cell", "input": "=DB(1,2,3,4)"})).is_err());
assert_ne!(recent(&mut s)[0], "DB");
}
#[test]
fn formulas_typed_with_a_plus_or_minus_count() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "+ROUND(1.5,0)"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "-SLN(100,10,5)"})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "-5"})).unwrap();
assert_eq!(&recent(&mut s)[..2], ["SLN", "ROUND"]);
}
#[test]
fn the_list_does_not_depend_on_the_journal() {
// The journal drops its oldest entries past its caps (and is public); the list stays.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
s.journal.clear();
assert_eq!(recent(&mut s)[0], "NPV");
s.execute("cell.set", json!({"cell": "A2", "input": "=SLN(100,10,5)"})).unwrap();
assert_eq!(&recent(&mut s)[..2], ["SLN", "NPV"]);
}
#[test]
fn the_list_is_kept_with_the_preferences() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SYD(100,10,5,1)"})).unwrap();
let saved = serde_json::to_value(&s.prefs).unwrap();
let mut next = Session::new();
next.new_workbook();
next.prefs = serde_json::from_value(saved).unwrap();
assert_eq!(recent(&mut next)[0], "SYD");
}
+2 -2
View File
@@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
}
(Some(c), None) if s.specifiers.is_empty() => {
// Excel writes a name with spaces at its edges in the double-bracket form.
if c.trim() == c {
// Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form.
if c.trim() == c && !c.starts_with('@') {
let _ = write!(out, "[{}]", esc(c));
} else {
let _ = write!(out, "[[{}]]", esc(c));
+9
View File
@@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box<dyn std::error::Error>>
}
Ok(())
}
#[test]
fn column_starting_with_at_round_trips() {
for name in ["@a", "a@b"] {
let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None };
let text = format!("={}", printer::struct_ref(&s));
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
}
}
+10 -3
View File
@@ -449,13 +449,15 @@ 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);
// Excel tests the end-31 rule against the original start day, before a February end moves to 30.
let start_30 = a >= 30;
if last_of_feb(&d1) && last_of_feb(&d2) {
b = 30;
}
if last_of_feb(&d1) || a == 31 {
a = 30;
}
if a == 30 && b == 31 {
if start_30 && b == 31 {
b = 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
@@ -817,8 +819,13 @@ mod tests {
#[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);
// Excel 16: a February-end start keeps its original day for the end-31 test, so 31 days.
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11);
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11);
close(ev("YEARFRAC", vec![n(d(2021.0, 3.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.086_111_111_111_111_11);
close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.077_777_777_777_777_78);
close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 30.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 1.0 / 6.0);
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 1.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);
}
+56 -7
View File
@@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R<String> {
Ok(number_to_text(n))
}
Value::Empty => Ok(String::new()),
v => Ok(v.to_text()?.trim().to_string()),
v => Ok(v.to_text()?.to_string()),
}
}
@@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
if v < min || v > max {
return Err(CellError::Num);
}
let places = places
.map(|p| {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
Ok(p as usize)
})
.transpose()?;
if v < 0 {
let u = v + (1i64 << bits);
return text_val(to_radix(u, radix));
@@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
match places {
None => text_val(s),
Some(p) => {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
let p = p as usize;
if s.len() > p {
return Err(CellError::Num);
}
@@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String {
}
fn places_arg(a: &[Arg]) -> R<Option<f64>> {
if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) }
if !has(a, 1) {
return Ok(None);
}
if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) {
return Err(CellError::Value);
}
Ok(Some(num(a, 1)?))
}
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
@@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn shift(a: &[Arg], left: bool) -> R<Value> {
let n = bit_operand(a, 0)?;
let s = num(a, 1)?.trunc();
if n == 0 {
return Ok(Value::Number(0.0));
}
if s.abs() > 53.0 {
return Err(CellError::Num);
}
@@ -402,6 +415,12 @@ mod tests {
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num);
is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num);
is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num);
is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value);
is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111");
is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF");
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
@@ -426,6 +445,21 @@ mod tests {
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
for (f, input) in [
("BIN2DEC", " 10"),
("BIN2DEC", "10 "),
("BIN2DEC", "\t10"),
("BIN2HEX", " 10"),
("BIN2OCT", "10 "),
("HEX2DEC", " 10"),
("HEX2BIN", "10 "),
("HEX2OCT", "\t10"),
("OCT2DEC", " 10"),
("OCT2BIN", "10 "),
("OCT2HEX", "\t10"),
] {
is_err(ev(f, vec![t(input)]), CellError::Num);
}
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
@@ -446,6 +480,21 @@ mod tests {
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
for f in ["BITLSHIFT", "BITRSHIFT"] {
for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] {
close(ev(f, vec![n(0.0), n(shift)]), 0.0);
}
for shift in [-100.0, -54.0, 54.0, 100.0] {
is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num);
}
if f == "BITLSHIFT" {
is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num);
} else {
close(ev(f, vec![n(1.0), n(53.0)]), 0.0);
}
is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value);
}
is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num);
}
#[test]
+4
View File
@@ -314,6 +314,10 @@ fn modulo(x: f64, d: f64) -> R<f64> {
if d == 0.0 {
return Err(CellError::Div0);
}
// Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16).
if (x / d).abs() >= 1.1259e12 {
return Err(CellError::Num);
}
// `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors.
let r = x % d;
// Keep the sign of the divisor and avoid returning |d| from rounding.
+15 -9
View File
@@ -140,16 +140,22 @@ fn rounding() {
}
#[test]
fn mod_tiny_divisor_stays_in_range() {
for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] {
match ev2("MOD", x, d) {
Value::Number(r) => {
assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}");
assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}");
}
other => panic!("MOD({x}, {d}) = {other:?}"),
}
fn mod_quotient_limit() {
// Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12.
for (x, d) in [
(3.0, 1e-300),
(-1.0, 1e-300),
(3.0, -1e-300),
(-1.0, -1e-300),
(1e15, 7.0),
(1e10, 0.001),
(1_125_900_000_000.0, 1.0),
(-1_125_900_000_000.0, 1.0),
] {
is_err(ev2("MOD", x, d), Num);
}
close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0);
close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375);
}
#[test]
+12 -1
View File
@@ -336,6 +336,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R<Value> {
if sig < 1.0 || s.is_empty() {
return Err(CellError::Num);
}
// Excel: 10^significance overflows from 309 up.
if sig >= 309.0 {
return Err(CellError::Div0);
}
let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0));
if x < first || x > last {
return Err(CellError::NA);
@@ -359,6 +363,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R<Value> {
let (rl, rh) = (rank_of(lo), rank_of(hi));
rl + (x - lo) / (hi - lo) * (rh - rl)
};
// From significance 15 up Excel returns 15 significant digits, rounded.
if sig >= 15.0 {
return num_val(format!("{r:.14e}").parse().unwrap_or(r));
}
num_val(trunc_digits(r, sig))
}
@@ -420,7 +428,10 @@ pub(super) fn geomean(v: &[f64]) -> R<f64> {
}
pub(super) fn harmean(v: &[f64]) -> R<f64> {
if v.is_empty() || v.iter().any(|&x| x <= 0.0) {
if v.is_empty() {
return Err(CellError::NA);
}
if v.iter().any(|&x| x <= 0.0) {
return Err(CellError::Num);
}
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
+40 -14
View File
@@ -22,7 +22,14 @@ fn t_cdf(t: f64, v: f64) -> f64 {
}
fn t_pdf(t: f64, v: f64) -> f64 {
(ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp()
let z = v / 2.0;
let log_gamma_ratio = if z >= 16.0 {
let inv_z = 1.0 / z;
0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0
} else {
ln_gamma(z + 0.5) - ln_gamma(z)
};
(log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp()
}
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
@@ -51,6 +58,12 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x == 1.0 && d1 == d2 {
return 0.5;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
}
@@ -58,6 +71,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 {
return 1.0;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
// With a much larger numerator degree, the scaled beta tail converges to a gamma tail.
return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
}
@@ -74,6 +91,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
}
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
if k < 0.0 || k > n {
return 0.0;
@@ -176,8 +195,14 @@ fn df_arg(a: &[Arg], i: usize) -> R<f64> {
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
let x = num(a, 0)?;
let v = df_arg(a, 1)?;
let v = num(a, 1)?.trunc();
let cum = boolean(a, 2)?;
if v == 0.0 {
return Err(if cum { CellError::Num } else { CellError::Div0 });
}
if v < 0.0 {
return Err(CellError::Num);
}
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
}
@@ -209,6 +234,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R<Value> {
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 0)?;
let v = df_arg(a, 1)?;
if p == 0.0 {
return Err(CellError::Div0);
}
if !(p > 0.0 && p < 1.0) {
return Err(CellError::Num);
}
@@ -347,7 +375,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> {
let x = num(a, 0)?;
let d1 = num(a, 1)?.trunc();
let d2 = num(a, 2)?.trunc();
if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 {
if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 {
return Err(CellError::Num);
}
Ok((x, d1, d2))
@@ -378,7 +406,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R<Value> {
if p == 0.0 {
return num_val(0.0);
}
num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60))
let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) };
num_val(value)
}
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
@@ -410,7 +439,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R<Value> {
let n = num(a, 1)?.trunc();
let p = num(a, 2)?;
let cum = boolean(a, 3)?;
if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) {
if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) {
return Err(CellError::Num);
}
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
@@ -421,7 +450,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 1)?;
let s1 = num(a, 2)?.trunc();
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
return Err(CellError::Num);
}
if s2 - s1 > 10_000.0 {
@@ -499,13 +528,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R<Value> {
return num_val(reg_gamma_p(al, x / be));
}
if x == 0.0 {
return if al < 1.0 {
Err(CellError::Num)
} else if al == 1.0 {
num_val(1.0 / be)
} else {
num_val(0.0)
};
return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) };
}
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
}
@@ -548,7 +571,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R<Value> {
if cum {
return num_val(reg_inc_beta(z, al, be));
}
if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) {
if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) {
return Err(CellError::Num);
}
let lp = if z == 0.0 || z == 1.0 {
@@ -608,6 +631,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R<Value> {
if x < 0.0 || al <= 0.0 || be <= 0.0 {
return Err(CellError::Num);
}
if x == 0.0 {
return num_val(0.0);
}
let t = (x / be).powf(al);
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
}
+115 -4
View File
@@ -151,6 +151,19 @@ fn spread() {
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
let text_values = arr(vec![vec![tv("1"), tv("2")]]);
let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]);
let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]);
is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA);
close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0);
close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0);
close(ev("HARMEAN", vec![b(true)]), 1.0);
close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num);
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
@@ -214,10 +227,19 @@ fn percentiles() {
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);
// Excel 16: significance 309 and up is #DIV/0!, 308 still works.
for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] {
is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0);
is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0);
close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5);
}
// From significance 15 up the result has 15 significant digits.
let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
for sig in [15.0, 16.0, 308.0] {
close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16);
}
close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66, 1e-15);
close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625);
}
#[test]
@@ -360,6 +382,7 @@ fn normal() {
#[test]
fn t_chi_f() {
close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10);
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
@@ -397,6 +420,7 @@ fn t_chi_f() {
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
@@ -404,6 +428,48 @@ fn t_chi_f() {
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
}
#[test]
fn f_distributions_accept_ten_billion_degrees() {
close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0);
close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28);
close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0);
close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8);
close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0);
is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num);
is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num);
}
#[test]
fn t_dist_zero_degrees_error_depends_on_mode() {
for df in [0.0, 0.9, -0.1] {
is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0);
is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num);
}
is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num);
close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
}
#[test]
fn t_density_is_stable_for_large_degrees() {
close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12);
close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12);
close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12);
}
#[test]
fn t_inv_zero_probability_returns_div0() {
is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0);
close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7);
is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num);
is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num);
}
#[test]
fn binom_inv_huge_trials_returns() {
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
@@ -427,7 +493,15 @@ fn discrete() {
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
@@ -466,6 +540,19 @@ fn continuous() {
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num);
close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0);
is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num);
close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25);
close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0);
close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0);
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
@@ -479,6 +566,30 @@ fn continuous() {
close_tol(r[0][1].clone(), 0.841344746, 1e-8);
}
#[test]
fn gamma_density_at_zero_shape_one_is_num() {
for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] {
is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num);
}
is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num);
}
#[test]
fn weibull_density_at_zero_is_zero() {
for shape in [0.5, 1.0, 2.0] {
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0);
}
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15);
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num);
}
#[test]
fn aggregate_helper() {
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
+113 -12
View File
@@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(text(a, 0)?.chars().count() as f64)
}
fn lower_char(ch: char) -> char {
match ch.to_lowercase().next() {
Some(lower) => lower,
None => ch,
}
}
fn upper_char(ch: char) -> char {
let mut uppercase = ch.to_uppercase();
match uppercase.next() {
Some(upper) if uppercase.next().is_none() => upper,
_ => ch,
}
}
fn proper_upper_char(ch: char) -> char {
if ch == '\u{130}' { 'I' } else { upper_char(ch) }
}
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_lowercase())
text_val(text(a, 0)?.chars().map(lower_char).collect::<String>())
}
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_uppercase())
text_val(text(a, 0)?.chars().map(upper_char).collect::<String>())
}
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
for ch in s.chars() {
if ch.is_alphabetic() {
if prev_letter {
out.extend(ch.to_lowercase());
out.push(lower_char(ch));
} else {
out.extend(ch.to_uppercase());
out.push(proper_upper_char(ch));
}
prev_letter = true;
} else {
@@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
if !(1.0..=1_114_111.0).contains(&n) {
return Err(CellError::Value);
}
match char::from_u32(n as u32) {
let cp = n as u32;
if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE {
return Err(CellError::NA);
}
match char::from_u32(cp) {
Some(c) => text_val(c.to_string()),
None => Err(CellError::NA),
}
@@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
let d = decimals.max(0) as usize;
let s = format!("{:.*}", d, x.abs());
let s = x.abs().to_string();
let (int_part, frac) = match s.split_once('.') {
Some((i, f)) => (i.to_string(), Some(f.to_string())),
None => (s.clone(), None),
Some((i, f)) => (i, f),
None => (s.as_str(), ""),
};
let int_part = if commas {
let b = int_part.as_bytes();
@@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
}
o
} else {
int_part
int_part.to_string()
};
let mut out = int_part;
if let Some(f) = frac {
if d > 0 {
let frac_len = frac.len().min(d);
out.push('.');
out.push_str(&f);
out.push_str(&frac[..frac_len]);
out.extend(std::iter::repeat_n('0', d - frac_len));
}
out
}
@@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?;
let no_commas = opt_bool(a, 2, false)?;
let body = format_fixed(r, d, !no_commas);
if body.len() + if r < 0.0 { 1 } else { 0 } > 255 {
return Err(CellError::Value);
}
text_val(if r < 0.0 { format!("-{body}") } else { body })
}
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?;
let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0);
let body = format_fixed(r, d, true);
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") })
let out = if negative { format!("(${body})") } else { format!("${body}") };
if out.len() > 254 {
return Err(CellError::Value);
}
text_val(out)
}
// ---------------------------------------------------------------------------------------------
@@ -1123,6 +1156,20 @@ mod tests {
fn case_and_cleanup() {
is_text(ev("UPPER", vec![t("abc")]), "ABC");
is_text(ev("LOWER", vec![t("ABC")]), "abc");
is_text(ev("UPPER", vec![t("Straße")]), "STRAßE");
is_text(ev("LOWER", vec![t("İ")]), "i");
is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul");
is_text(ev("PROPER", vec![t("ß")]), "ß");
is_text(ev("PROPER", vec![t("ÉCOLE")]), "École");
let proper = ev("PROPER", vec![t("İSTANBUL Straße")]);
is_text(proper.clone(), "Istanbul Straße");
assert_eq!(
match proper {
Value::Text(s) => s.chars().count(),
_ => 0,
},
"Istanbul Straße".chars().count()
);
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
@@ -1181,8 +1228,15 @@ mod tests {
close(ev("CODE", vec![t("€")]), 128.0);
is_err(ev("CODE", vec![t("")]), CellError::Value);
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] {
is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA);
}
for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] {
is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected);
}
close(ev("UNICODE", vec![t("☃")]), 9731.0);
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
}
@@ -1237,10 +1291,57 @@ mod tests {
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
let fixed_16 = format!("1.1{}", "0".repeat(15));
let dollar_16 = format!("$1.1{}", "0".repeat(15));
let fixed_20 = format!("1.005{}", "0".repeat(17));
let dollar_20 = format!("$1.005{}", "0".repeat(17));
let fixed_127 = format!("1.1{}", "0".repeat(126));
let dollar_127 = format!("$1.1{}", "0".repeat(126));
is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16);
is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16);
is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20);
is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20);
is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127);
is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127);
is_text(ev("T", vec![t("x")]), "x");
is_text(ev("T", vec![n(1.0)]), "");
}
#[test]
fn fixed_and_dollar_output_limits() {
assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-')));
is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value);
is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value);
}
#[test]
fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() {
is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)");
is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)");
is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)");
is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0");
}
#[test]
fn before_after() {
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
+16
View File
@@ -252,6 +252,22 @@ fn save_and_open_roundtrip() {
let _ = std::fs::remove_file(path);
}
/// #2255: `parse: false` on `write_range` stores strings as the text they are, so a dd.mm.yyyy
/// date or a thousands separator survives, and the result names the strings the default read.
#[test]
fn write_range_can_store_strings_as_text() {
let mut s = server();
let row = json!([["05.04.2026", "2.500", "4.25 %", "=1+1"]]);
let stored = ok(&mut s, "write_range", json!({"range": "A1", "parse": false, "values": row}));
assert_eq!(stored["convertedCount"], 0, "nothing was read: {stored}");
let v = ok(&mut s, "read_range", json!({"range": "A1:D1"}));
assert_eq!(v["values"], json!([["05.04.2026", "2.500", "4.25 %", 2.0]]), "{v}");
// Without it the strings are read like typed input, and the result says which.
let read = ok(&mut s, "write_range", json!({"range": "A3", "values": row}));
assert_eq!(read["convertedCount"], 3, "{read}");
assert_eq!(read["converted"][0], json!({"cell": "A3", "input": "05.04.2026", "stored": "5/4/2026"}), "{read}");
}
/// An agent builds a small monthly budget through MCP tool calls only.
#[test]
fn agent_builds_a_budget() {
+4 -2
View File
@@ -190,11 +190,13 @@ fn base_tool_definitions() -> Vec<Value> {
"Write range",
"Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \
and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \
input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.",
input (\"12%\", \"1/2/2024\", \"$5\") unless `parse` is false, which stores them as the text they are. The result's \
`converted` lists the strings that were read as something else. One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.",
obj(
json!({
"range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""),
"values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}},
"parse": boolean("Read strings not starting with = like typed input, so \"05.04.2026\" becomes a date (default true); false stores them as the text they are, and \"\" clears the cell"),
}),
&["range", "values"],
),
@@ -637,7 +639,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<Value, String>
"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"])),
"write_range" => exec(b, "range.setValues", pick(a, &["range", "values", "parse"])),
"set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])),
"get_cell" => exec(b, "cell.get", pick(a, &["cell"])),
"evaluate_formula" => {
+1 -1
View File
@@ -27,7 +27,7 @@ pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
VAlign, VertAlign,
};
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook};
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView};
/// Font used for new workbooks. Only a name: renderers substitute an available open font
/// (e.g. the metric-compatible Carlito) when it isn't installed.
+49
View File
@@ -30,6 +30,51 @@ pub enum ShowObjects {
None,
}
/// The workbook window's display settings from `<workbookView>` (Excel: File › Options ›
/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs
/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`,
/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this
/// share to 0–1000, including values supplied directly through the model.
///
/// The values are modelled so an open + save keeps them — a workbook Excel saved with the
/// tabs hidden must not come back with them shown and the settings gone. The app does not
/// act on them yet and there is no Options control for them; that is the other half of
/// storytold/gridcraft#3057.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkbookView {
#[serde(default = "yes")]
pub show_sheet_tabs: bool,
#[serde(default = "yes")]
pub show_horizontal_scroll: bool,
#[serde(default = "yes")]
pub show_vertical_scroll: bool,
#[serde(default = "default_tab_ratio")]
pub tab_ratio: u32,
}
fn yes() -> bool {
true
}
fn default_tab_ratio() -> u32 {
600
}
impl Default for WorkbookView {
fn default() -> Self {
WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: true, show_vertical_scroll: true, tab_ratio: default_tab_ratio() }
}
}
impl WorkbookView {
/// The entire bar, in thousandths: the upper bound used by XLSX I/O.
pub const MAX_TAB_RATIO: u32 = 1000;
fn is_default(&self) -> bool {
*self == WorkbookView::default()
}
}
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CalcSettings {
@@ -150,6 +195,9 @@ pub struct Workbook {
pub show_objects: ShowObjects,
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
pub active_sheet: ActiveSheet,
/// The workbook window's display settings (`<workbookView>`), kept across an open + save.
#[serde(default, skip_serializing_if = "WorkbookView::is_default")]
pub view: WorkbookView,
pub props: DocProps,
/// Workbook structure protection.
pub protected_structure: bool,
@@ -284,6 +332,7 @@ impl Workbook {
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
active_sheet: ActiveSheet::default(),
view: WorkbookView::default(),
props: DocProps::default(),
protected_structure: false,
structure_password: None,
+1 -1
View File
@@ -11,7 +11,7 @@ workspace = true
[dependencies]
# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
gridcraft-engine = { workspace = true }
gridcraft-chart = { workspace = true }
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
+3 -4
View File
@@ -1,8 +1,7 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose
//! people.toml entry is verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`).
//!
+1
View File
@@ -75,6 +75,7 @@ impl SheetApp {
macros: macro_ui::MacroUi::default(),
started: now_ms(),
allow_close: false,
bring_forward: false,
open_switch_windows: false,
ui_window: None,
parked: HashMap::new(),
+88
View File
@@ -15,6 +15,13 @@ impl SheetApp {
let Some(d) = self.session.active() else { return };
let Some(sh) = d.wb.active() else { return };
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
// A locked cell on a protected sheet can't be entered (Excel's warning, and the engine
// refuses `cell.set` anyway): refuse before a draft exists, rather than leaving one that
// can never be committed and blocks Save (#2945).
if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) {
self.message = Some(("GridCraft".into(), m));
return;
}
// A picture has no editable scalar text. Selecting it, F2, or clicking
// the formula bar must not turn its fallback value into a text edit.
// Typing still starts an intentional replacement.
@@ -102,6 +109,13 @@ impl SheetApp {
}
return true;
}
// A draft on a cell sheet protection now locks (the sheet was protected while it was
// open) can't be entered: drop it with the warning instead of keeping it, which would
// block Save with a draft that can never be committed (#2945).
if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) {
self.message = Some(("GridCraft".into(), m));
return true;
}
let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) {
Some(Ok(text)) => text,
Some(Err(error)) => {
@@ -209,3 +223,77 @@ impl SheetApp {
self.grid.ensure_visible = true;
}
}
#[cfg(test)]
mod protected_cell_tests {
use super::*;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
/// A protected sheet whose C2 is unlocked; C2 holds "keep" as the last undo step.
fn protected_book() -> SheetApp {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.run("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap();
app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap();
app.run("review.protectSheet", json!({})).unwrap();
app
}
fn value(app: &SheetApp, a1: &str) -> Value {
let at = CellRef::parse(a1).unwrap();
app.session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default()
}
fn protected_message(app: &SheetApp) -> Option<&str> {
app.message.as_ref().map(|(_, m)| m.as_str())
}
#[test]
fn typing_in_a_locked_cell_does_not_leave_a_draft_that_blocks_undo_and_save() {
let mut app = protected_book();
assert_eq!(app.session.active().unwrap().selection.active.a1(), "A1", "the locked A1 is the active cell");
let undo = app.session.active().unwrap().undo.len();
let dirty = app.session.active().unwrap().is_dirty();
app.begin_edit(Some("locked".into()), false);
assert!(app.editor.is_none(), "a locked cell must not open an editor");
assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet")), "the warning names the protected sheet");
assert_eq!(app.session.active().unwrap().undo.len(), undo, "a refused edit adds no undo step");
assert_eq!(app.session.active().unwrap().is_dirty(), dirty, "a refused edit leaves the workbook as it was");
assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok(), "Save is not blocked by a draft that can never be entered");
}
#[test]
fn an_unlocked_cell_on_a_protected_sheet_still_edits_undoes_and_saves() {
let mut app = protected_book();
app.run("selection.set", json!({"cell": "C2"})).unwrap();
app.begin_edit(Some("typed".into()), false);
assert!(app.editor.is_some(), "an unlocked cell opens its editor");
assert!(app.commit_edit(0, 0, false, false));
assert_eq!(value(&app, "C2"), Value::text("typed"));
assert!(app.run("edit.undo", json!({})).is_ok());
assert_eq!(value(&app, "C2"), Value::text("keep"), "Undo of the typed edit still works");
assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
}
#[test]
fn a_draft_that_becomes_locked_is_cancelled_instead_of_staying_open() {
let mut app = protected_book();
app.run("selection.set", json!({"cell": "C2"})).unwrap();
app.begin_edit(Some("typed".into()), false);
// The sheet is unprotected, C2 locked, and the sheet protected again while the draft is open.
app.run("review.unprotectSheet", json!({})).unwrap();
app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": true}}})).unwrap();
app.run("review.protectSheet", json!({})).unwrap();
assert!(app.commit_edit(0, 0, false, false), "the refused draft is handled, not left open");
assert!(app.editor.is_none());
assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet")));
assert_eq!(value(&app, "C2"), Value::text("keep"));
assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
}
}
+1
View File
@@ -42,6 +42,7 @@ impl SheetApp {
self.enter_window(window);
let title = self.session.active().map(|d| d.display_title()).unwrap_or_default();
self.open_dialog("saveChanges", json!({"title": title, "quit": true}));
self.bring_forward = true;
}
None => {
self.allow_close = true;
+5 -1
View File
@@ -114,6 +114,8 @@ pub struct Backstage {
pub page: Page,
/// Info: property drafts while a field is being edited (committed when it loses focus).
props: [String; 6],
/// Focus at the end of the previous frame, before egui clears it on Esc.
text_edit_focused: bool,
/// Print: the whole workbook rather than the active sheet.
pub print_all: bool,
/// Print: fit each sheet on one page.
@@ -138,6 +140,7 @@ impl Backstage {
Backstage {
page,
props: Default::default(),
text_edit_focused: false,
print_all: false,
fit_to_page: false,
preview_page: 0,
@@ -160,7 +163,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let Some(mut bs) = app.backstage.take() else { return };
let ctx = ui.ctx().clone();
// Esc inside a text field leaves the field; it closes the view only from outside one.
let typing = ctx.text_edit_focused();
let typing = bs.text_edit_focused;
let t = Tokens::get(&ctx);
let lang = app.ui.language;
let mut out = Outcome::default();
@@ -229,6 +232,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
});
});
});
bs.text_edit_focused = ctx.text_edit_focused();
if !typing
&& app.dialog.is_none()
&& app.message.is_none()
+1 -2
View File
@@ -1,7 +1,6 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! read at run time.
//! build.rs from contributors/contributors.json; nothing is read at run time.
use std::cmp::Ordering;
+7 -1
View File
@@ -853,7 +853,13 @@ impl Dialog {
}
d
}
"insertFunction" => Dialog::custom("insertFunction", "Formula Builder", json!({"category": "Most Recently Used"})),
"insertFunction" => {
// A Function Library menu opens it on its own category.
let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string();
let mut d = Dialog::custom("insertFunction", "Insert Function", json!({"category": category}));
d.focus_first_field = true;
d
}
"find" => {
let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"}));
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
+94 -38
View File
@@ -1,33 +1,97 @@
use super::show::ok_cancel;
use super::{Dialog, Json, SheetApp, vec2};
use super::show::{ok_cancel, take_first_focus};
use super::{Dialog, Json, SheetApp, json, vec2};
/// Insert Function: searches names (as the formula language spells them) and descriptions, shows
/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula).
/// Insert Function: search names (as the formula language spells them) and descriptions, or pick
/// a category (Most Recently Used, All or one category), then a function; each hit shows its
/// category and description. OK starts editing `=NAME(` (or appends to the formula).
pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
use crate::function_library::{self as lib, ALL, MOST_RECENTLY_USED};
let language = app.ui.language;
ui.horizontal(|ui| {
ui.label(language.tr("Search:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date")));
r.request_focus();
// Once only, so Tab can move on to the category list.
take_first_focus(d, &r);
});
// A new search (typed, or set through the control channel) looks through every function, as
// Excel's does.
if d.values.get("searched").and_then(Json::as_str) != Some(d.search.as_str()) {
d.list_index = 0;
if !d.search.trim().is_empty() {
d.values.insert("category".into(), json!(ALL));
}
d.values.insert("searched".into(), json!(d.search));
}
// The category may be given by label or by engine id (a Function Library menu, `ui.dialog.set`).
let saved = d.values.get("category").and_then(Json::as_str).unwrap_or(MOST_RECENTLY_USED).to_string();
let mut category = if saved.eq_ignore_ascii_case(ALL) {
ALL.to_string()
} else {
lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string()
};
// The category list is the dialog's only menu, so a menu open as the frame starts is its own
// (Enter on a row picks it and closes the menu in this frame).
let menu_was_open = egui::Popup::is_any_open(ui.ctx());
let picker = ui.horizontal(|ui| {
let label = ui.label(language.tr("Or select a category:"));
craft_ui::ComboBox::from_id_salt("insertFunctionCategory")
.width(220.0)
.selected_text(language.tr(&category))
.show_ui(ui, |ui| {
for c in [MOST_RECENTLY_USED, ALL].into_iter().chain(lib::CATEGORIES.iter().map(|(_, l)| *l)) {
ui.selectable_value(&mut category, c.to_string(), language.tr(c));
}
})
.response
.labelled_by(label.id)
});
// Enter on the category list or in its menu opens it or picks a category, as Space does
// (egui clicks a focused control on either key); it does not also confirm the dialog.
let picker = picker.inner;
if menu_was_open || picker.has_focus() || craft_ui::ComboBox::is_open(ui.ctx(), picker.id) {
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
if category != saved {
d.list_index = 0;
}
d.values.insert("category".into(), json!(category));
let locale = language.formula_locale();
let list = gridcraft_engine::cmd::formulas::function_list();
// The names and descriptions searched are the ones shown, so in the interface language.
let q = d.search.to_lowercase();
let hits: Vec<&Json> = list
.iter()
.filter(|f| {
q.is_empty()
|| f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q))
|| f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q))
})
.take(300)
.collect();
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (i, f) in hits.iter().enumerate() {
let name = locale.function_name(f["name"].as_str().unwrap_or(""));
let category = language.tr(function_category_label(f["category"].as_str().unwrap_or("")));
let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0)));
let q = d.search.trim().to_lowercase();
let matches = |f: &lib::FnEntry| {
q.is_empty()
|| locale.function_name(&f.name).to_lowercase().contains(&q)
|| language.function_description(&f.description).unwrap_or(&f.description).to_lowercase().contains(&q)
};
let hits: Vec<&lib::FnEntry> = if category == MOST_RECENTLY_USED {
lib::recent(app).iter().filter_map(|n| lib::find(n)).filter(|f| matches(f)).collect()
} else {
let id = lib::category_id(&category);
// The catalog is fixed (some 500 functions), so All lists every one.
lib::all().iter().filter(|f| id.is_none_or(|id| f.category == id) && matches(f)).collect()
};
let t = crate::theme::Tokens::get(ui.ctx());
let font = egui::TextStyle::Body.resolve(ui.style());
let rows: Vec<(&str, &str)> = hits.iter().map(|f| (locale.function_name(&f.name), language.tr(lib::category_label(&f.category)))).collect();
// The category column starts after the widest name shown, so a long name in any formula
// language or font never runs into it.
let name_w = rows.iter().map(|(n, _)| ui.painter().layout_no_wrap(n.to_string(), font.clone(), t.text).size().x).fold(0.0_f32, f32::max);
egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, false]).show(ui, |ui| {
if rows.is_empty() {
ui.label(egui::RichText::new(language.tr("No functions match.")).italics());
}
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
for (i, (name, category)) in rows.iter().enumerate() {
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width().max(name_w + 120.0), row_h), egui::Sense::click());
if i == d.list_index {
ui.painter().rect_filled(rect, 3.0, ui.visuals().selection.bg_fill);
} else if r.hovered() {
ui.painter().rect_filled(rect, 3.0, t.hover);
}
let color = if i == d.list_index { ui.visuals().selection.stroke.color } else { t.text };
ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, *name, font.clone(), color);
ui.painter().text(rect.left_center() + vec2(6.0 + name_w + 16.0, 0.0), egui::Align2::LEFT_CENTER, *category, font.clone(), t.text_dim);
r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == d.list_index, format!("{name} ({category})")));
if r.clicked() {
d.list_index = i;
}
@@ -38,15 +102,19 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia
}
});
if let Some(f) = hits.get(d.list_index) {
let name = f["name"].as_str().unwrap_or("");
ui.separator();
ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong());
ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default());
ui.label(egui::RichText::new(crate::formula_locale::signature(&f.name, locale, language).unwrap_or_default()).strong());
ui.label(crate::formula_locale::description(&f.name, locale, language).unwrap_or_default());
}
let mut open = true;
ok_cancel(ui, confirm, &mut open);
if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n);
// The function counts as recently used once the formula is entered (the engine notes it).
if *confirm && let Some(f) = hits.get(d.list_index) {
let n = app
.session
.active()
.map(|doc| crate::formula_locale::completion_name(&f.name, locale, &doc.wb, doc.wb.active_sheet()))
.unwrap_or(&f.name);
let cur = app.editor.as_ref().map(|e| e.text.clone());
let text = match cur {
Some(t) if t.starts_with('=') => format!("{t}{n}("),
@@ -58,15 +126,3 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia
*confirm = true;
}
}
/// Excel's name for a function category, given the catalog's id (`MathTrig`).
fn function_category_label(id: &str) -> &str {
match id {
"DateTime" => "Date & Time",
"Lookup" => "Lookup & Reference",
"MathTrig" => "Math & Trig",
"AddIn" => "Add-in and Automation",
// Financial, Logical, Text, Statistical, Engineering, Database, Information, …
other => other,
}
}
+1 -1
View File
@@ -464,7 +464,7 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
/// Enter sets the width, as in Excel.
/// Keeps asking until the field really has focus: a new window's first frame is an invisible
/// sizing pass where a focus request is ignored.
fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
pub(super) fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
if !d.focus_first_field {
return;
}
+9
View File
@@ -160,6 +160,15 @@ impl DocWindows {
self.list.focus(id);
self.viewports.focus(ctx, id);
}
// macOS ignores Focus for a minimized window or a hidden app, so restore and show first.
if std::mem::take(&mut app.bring_forward)
&& let Some(id) = self.list.active()
{
let vp = self.viewports.viewport_id(id);
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Focus);
}
}
/// Draws every window. Call from the root viewport's UI each frame, in place of
+191
View File
@@ -0,0 +1,191 @@
//! The Formulas tab's Function Library: the function categories, their menus, the AutoSum menu
//! (shared with Home › Editing), and the Recently Used menu, which lists what the engine's
//! `formulas.recentlyUsed` returns.
use std::sync::OnceLock;
use egui::{Ui, vec2};
use serde_json::json;
use crate::SheetApp;
/// The Insert Function category that lists [`recent`].
pub const MOST_RECENTLY_USED: &str = "Most Recently Used";
/// The Insert Function category that lists every function.
pub const ALL: &str = "All";
/// The function categories in the order the Insert Function dialog lists them: (the engine's
/// category id, the label people see).
pub const CATEGORIES: [(&str, &str); 14] = [
("Financial", "Financial"),
("DateTime", "Date & Time"),
("MathTrig", "Math & Trig"),
("Statistical", "Statistical"),
("Lookup", "Lookup & Reference"),
("Database", "Database"),
("Text", "Text"),
("Logical", "Logical"),
("Information", "Information"),
("Engineering", "Engineering"),
("Cube", "Cube"),
("Compatibility", "Compatibility"),
("Web", "Web"),
("AddIn", "Add-in and Automation"),
];
/// The categories under More Functions, each a cascading submenu.
pub const MORE_FUNCTIONS: [&str; 6] = ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"];
/// The label of an engine category id (the id itself if it is unknown).
pub fn category_label(id: &str) -> &str {
CATEGORIES.iter().find(|(c, _)| *c == id).map(|(_, l)| *l).unwrap_or(id)
}
/// The engine category id for a category given by id or label, in any case.
pub fn category_id(name: &str) -> Option<&'static str> {
CATEGORIES.iter().find(|(c, l)| c.eq_ignore_ascii_case(name) || l.eq_ignore_ascii_case(name)).map(|(c, _)| *c)
}
/// One function of the library.
#[derive(Clone, Debug)]
pub struct FnEntry {
pub name: String,
pub category: String,
pub description: String,
}
/// Every function, sorted by name (built once; the list is static).
pub fn all() -> &'static [FnEntry] {
static LIST: OnceLock<Vec<FnEntry>> = OnceLock::new();
LIST.get_or_init(|| {
let mut list: Vec<FnEntry> = gridcraft_engine::cmd::formulas::function_list()
.iter()
.filter_map(|f| {
Some(FnEntry {
name: f["name"].as_str()?.to_string(),
category: f["category"].as_str().unwrap_or("").to_string(),
description: f["description"].as_str().unwrap_or("").to_string(),
})
})
.collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
list.dedup_by(|a, b| a.name == b.name);
list
})
}
/// The function named `name` (upper case), if the library knows it.
pub fn find(name: &str) -> Option<&'static FnEntry> {
let list = all();
list.binary_search_by(|f| f.name.as_str().cmp(name)).ok().and_then(|i| list.get(i))
}
/// The functions of one engine category, in name order.
pub fn in_category(id: &str) -> impl Iterator<Item = &'static FnEntry> + '_ {
all().iter().filter(move |f| f.category == id)
}
/// The name a function shows under in the interface language.
fn display_name<'a>(app: &SheetApp, name: &'a str) -> &'a str {
let locale = app.ui.language.formula_locale();
app.session.active().map(|d| crate::formula_locale::completion_name(name, locale, &d.wb, d.wb.active_sheet())).unwrap_or(name)
}
/// One menu row per function, with its description on hover; a click starts the formula.
fn function_items<'a>(app: &mut SheetApp, ui: &mut Ui, names: impl Iterator<Item = &'a str>) {
let language = app.ui.language;
let locale = language.formula_locale();
ui.set_max_height(420.0);
egui::ScrollArea::vertical().max_height(380.0).show(ui, |ui| {
for n in names {
let Some(entry) = find(n) else { continue };
let name = display_name(app, &entry.name);
let r = ui.add(egui::Button::new(name).frame(false).min_size(vec2(180.0, 20.0)));
// The description is looked up only while the row is hovered.
let r = r.on_hover_ui(|ui| {
ui.label(crate::formula_locale::description(&entry.name, locale, language).unwrap_or_default());
});
if r.clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": entry.name}));
ui.close();
}
}
});
}
/// The "Insert Function…" row that ends every category menu; it opens the dialog on `category`.
fn insert_function_item(app: &mut SheetApp, ui: &mut Ui, category: &str) {
ui.separator();
let label = app.ui.language.tr("Insert Function…");
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 22.0))).clicked() {
app.open_dialog("insertFunction", json!({"category": category}));
ui.close();
}
}
/// The menu of one category: its functions, then Insert Function….
pub fn category_menu(app: &mut SheetApp, ui: &mut Ui, id: &str) {
let names: Vec<&str> = in_category(id).map(|f| f.name.as_str()).collect();
function_items(app, ui, names.into_iter());
insert_function_item(app, ui, category_label(id));
}
/// The functions `formulas.recentlyUsed` lists, newest first (none without a workbook).
pub fn recent(app: &mut SheetApp) -> Vec<String> {
let list = app.session.execute("formulas.recentlyUsed", json!({})).unwrap_or_default();
list.as_array().map(|a| a.iter().filter_map(|n| n.as_str().map(String::from)).collect()).unwrap_or_default()
}
/// Recently Used: the most recent functions, newest first, then Insert Function….
pub fn recent_menu(app: &mut SheetApp, ui: &mut Ui) {
let recent = recent(app);
function_items(app, ui, recent.iter().map(String::as_str));
insert_function_item(app, ui, MOST_RECENTLY_USED);
}
/// More Functions: a cascading submenu per remaining category.
pub fn more_menu(app: &mut SheetApp, ui: &mut Ui) {
for id in MORE_FUNCTIONS {
let label = app.ui.language.tr(category_label(id)).to_string();
ui.menu_button(label, |ui| category_menu(app, ui, id));
}
}
/// The AutoSum menu, shared by Home › Editing and Formulas › Function Library: a function, or
/// the Insert Function dialog.
pub fn auto_sum_menu(app: &mut SheetApp, ui: &mut Ui) {
for (label, f) in [("Sum", "SUM"), ("Average", "AVERAGE"), ("Count Numbers", "COUNT"), ("Max", "MAX"), ("Min", "MIN")] {
let label = app.ui.language.tr(label);
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(160.0, 22.0))).clicked() {
app.run_or_alert("formulas.autoSum", json!({"function": f}));
ui.close();
}
}
ui.separator();
let label = app.ui.language.tr("More Functions…");
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(160.0, 22.0))).clicked() {
app.open_dialog("insertFunction", json!({}));
ui.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn categories_cover_every_function() {
for f in all() {
assert!(category_id(&f.category).is_some(), "{} is in an unknown category {}", f.name, f.category);
}
assert_eq!(category_id("date & time"), Some("DateTime"));
assert_eq!(category_id("Lookup"), Some("Lookup"));
assert_eq!(category_id("nope"), None);
for c in MORE_FUNCTIONS {
assert!(category_id(c).is_some());
}
assert!(all().windows(2).all(|w| w[0].name < w[1].name), "sorted by name, without duplicates");
assert_eq!(find("SUM").map(|f| f.category.as_str()), Some("MathTrig"));
assert!(find("NOT_A_FUNCTION").is_none());
}
}
+5 -4
View File
@@ -417,11 +417,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Dlouhé datum",
"More Number Formats…" => "Další formáty čísel…",
"Financial" => "Finanční",
"Recently Used" => "Naposledy použité",
"Logical" => "Logické",
"Date &\nTime" => "Datum a\nčas",
"Lookup &\nReference" => "Vyhledávání\na odkazy",
"Math &\nTrig" => "Matematika a\ntrigonometrie",
"More\nFunctions" => "Další\nfunkce",
"Date & Time" => "Datum a čas",
"Lookup & Reference" => "Vyhledávání a odkazy",
"Math & Trig" => "Matematika a trigonometrie",
"More Functions" => "Další funkce",
"Sheet Options|View" => "Zobrazit",
"Print" => "Tisknout",
"Print What" => "Co tisknout",
+5 -4
View File
@@ -420,11 +420,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Langes Datum",
"More Number Formats…" => "Weitere Zahlenformate…",
"Financial" => "Finanzmathematik",
"Recently Used" => "Zuletzt verwendet",
"Logical" => "Logisch",
"Date &\nTime" => "Datum &\nUhrzeit",
"Lookup &\nReference" => "Nachschlagen &\nVerweisen",
"Math &\nTrig" => "Mathematik &\nTrigonometrie",
"More\nFunctions" => "Weitere\nFunktionen",
"Date & Time" => "Datum & Uhrzeit",
"Lookup & Reference" => "Nachschlagen & Verweisen",
"Math & Trig" => "Mathematik & Trigonometrie",
"More Functions" => "Weitere Funktionen",
"Page Layout|Orientation" => "Seitenausrichtung",
"Display theme" => "Anzeigedesign",
"System" => "System",
+1 -5
View File
@@ -389,10 +389,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Περισσότερες μορφές αριθμών…",
"Financial" => "Οικονομικές",
"Logical" => "Λογικές",
"Date &\nTime" => "Ημερομηνία και\nώρα",
"Lookup &\nReference" => "Αναζήτηση και\nαναφορά",
"Math &\nTrig" => "Μαθηματικά και\nτριγωνομετρία",
"More\nFunctions" => "Περισσότερες\nσυναρτήσεις",
"More Functions" => "Περισσότερες συναρτήσεις",
"Page Layout|Orientation" => "Προσανατολισμός σελίδας",
"Display theme" => "Θέμα εμφάνισης",
"System" => "Σύστημα",
@@ -580,7 +577,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Επεξεργασία ονόματος",
"New Name" => "Νέο όνομα",
"Format Cells" => "Μορφοποίηση κελιών",
"Formula Builder" => "Δημιουργός τύπων",
"Find and Replace" => "Εύρεση και αντικατάσταση",
"Go To" => "Μετάβαση",
"Insert Calculated Field" => "Εισαγωγή υπολογιζόμενου πεδίου",
+5 -4
View File
@@ -417,11 +417,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Fecha larga",
"More Number Formats…" => "Más formatos de número…",
"Financial" => "Financieras",
"Recently Used" => "Usadas recientemente",
"Logical" => "Lógicas",
"Date &\nTime" => "Fecha\ny hora",
"Lookup &\nReference" => "Búsqueda y\nreferencia",
"Math &\nTrig" => "Matemáticas y\ntrigonométricas",
"More\nFunctions" => "Más\nfunciones",
"Date & Time" => "Fecha y hora",
"Lookup & Reference" => "Búsqueda y referencia",
"Math & Trig" => "Matemáticas y trigonométricas",
"More Functions" => "Más funciones",
"Page Layout|Orientation" => "Orientación",
"Display theme" => "Tema de la interfaz",
"System" => "Sistema",
+5 -4
View File
@@ -308,11 +308,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Date longue",
"More Number Formats…" => "Autres formats numériques…",
"Financial" => "Financières",
"Recently Used" => "Récemment utilisées",
"Logical" => "Logiques",
"Date &\nTime" => "Date et\nheure",
"Lookup &\nReference" => "Recherche et\nréférence",
"Math &\nTrig" => "Maths et\ntrigonométrie",
"More\nFunctions" => "Autres\nfonctions",
"Date & Time" => "Date et heure",
"Lookup & Reference" => "Recherche et référence",
"Math & Trig" => "Maths et trigonométrie",
"More Functions" => "Autres fonctions",
"Sheet Options|View" => "Affichage",
"Print" => "Impression",
"Print What" => "Quoi imprimer",
+1 -5
View File
@@ -291,10 +291,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Altri formati numero…",
"Financial" => "Finanziarie",
"Logical" => "Logiche",
"Date &\nTime" => "Data e\nora",
"Lookup &\nReference" => "Ricerca e\nriferimento",
"Math &\nTrig" => "Matematiche e\ntrigonometriche",
"More\nFunctions" => "Altre\nfunzioni",
"More Functions" => "Altre funzioni",
"Page Layout|Orientation" => "Orientamento",
"Display theme" => "Tema di visualizzazione",
"Animations" => "Animazioni",
@@ -456,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Modifica nome",
"New Name" => "Nuovo nome",
"Format Cells" => "Formato celle",
"Formula Builder" => "Generatore di formule",
"Find and Replace" => "Trova e sostituisci",
"Go To" => "Vai a",
"Insert Calculated Field" => "Inserisci campo calcolato",
+2 -5
View File
@@ -304,10 +304,8 @@ pub(super) const CORE: &[(&str, &str)] = &[
("More Number Formats…", "その他の表示形式…"),
("Financial", "財務"),
("Logical", "論理"),
("Date &\nTime", "日付/時刻"),
("Lookup &\nReference", "検索/行列"),
("Math &\nTrig", "数学/三角"),
("More\nFunctions", "その他の関数"),
("Recently Used", "最近使用した関数"),
("More Functions", "その他の関数"),
("Sheet Options|View", "画面"),
("Print", "印刷"),
("Print What", "印刷する内容"),
@@ -477,7 +475,6 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
("Edit Name", "名前の編集"),
("New Name", "新しい名前"),
("Format Cells", "セルの書式設定"),
("Formula Builder", "数式ビルダー"),
("Find and Replace", "検索と置換"),
("Go To", "ジャンプ"),
("Insert Calculated Field", "計算のフィールドの挿入"),
+5 -4
View File
@@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "자세한 날짜",
"More Number Formats…" => "숫자 서식 더 보기…",
"Financial" => "재무",
"Recently Used" => "최근 사용 항목",
"Logical" => "논리",
"Date &\nTime" => "날짜 및\n시간",
"Lookup &\nReference" => "조회 및\n참조",
"Math &\nTrig" => "수학 및\n삼각",
"More\nFunctions" => "기타\n함수",
"Date & Time" => "날짜 및 시간",
"Lookup & Reference" => "조회 및 참조",
"Math & Trig" => "수학 및 삼각",
"More Functions" => "기타 함수",
"Page Layout|Orientation" => "용지 방향",
"Display theme" => "표시 테마",
"Animations" => "애니메이션",
+4 -4
View File
@@ -300,10 +300,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Meer getalnotaties…",
"Financial" => "Financieel",
"Logical" => "Logisch",
"Date &\nTime" => "Datum en\ntijd",
"Lookup &\nReference" => "Zoeken en\nverwijzen",
"Math &\nTrig" => "Wiskunde en\ntrigonometrie",
"More\nFunctions" => "Meer\nfuncties",
"Date & Time" => "Datum en tijd",
"Lookup & Reference" => "Zoeken en verwijzen",
"Math & Trig" => "Wiskunde en trigonometrie",
"More Functions" => "Meer functies",
"Sheet Options|View" => "Weergeven",
"Print" => "Afdrukken",
"Print What" => "Wat afdrukken",
+1 -5
View File
@@ -287,10 +287,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Więcej formatów liczb…",
"Financial" => "Finansowe",
"Logical" => "Logiczne",
"Date &\nTime" => "Data i\ngodzina",
"Lookup &\nReference" => "Wyszukiwanie\ni odwołania",
"Math &\nTrig" => "Matematyczne i\ntrygonometryczne",
"More\nFunctions" => "Więcej\nfunkcji",
"More Functions" => "Więcej funkcji",
"Page Layout|Orientation" => "Orientacja",
"Display theme" => "Motyw wyświetlania",
"Animations" => "Animacje",
@@ -450,7 +447,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Edytowanie nazwy",
"New Name" => "Nowa nazwa",
"Format Cells" => "Formatowanie komórek",
"Formula Builder" => "Konstruktor formuł",
"Find and Replace" => "Znajdowanie i zamienianie",
"Go To" => "Przechodzenie do",
"Insert Calculated Field" => "Wstawianie pola obliczeniowego",
+2 -5
View File
@@ -291,11 +291,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Data Por Extenso",
"More Number Formats…" => "Mais Formatos de Número…",
"Financial" => "Financeira",
"Recently Used" => "Usadas Recentemente",
"Logical" => "Lógica",
"Date &\nTime" => "Data e\nHora",
"Lookup &\nReference" => "Pesquisa e\nReferência",
"Math &\nTrig" => "Matemática e\nTrigonometria",
"More\nFunctions" => "Mais\nFunções",
"More Functions" => "Mais Funções",
"Page Layout|Orientation" => "Orientação",
"Display theme" => "Tema de exibição",
"Animations" => "Animações",
@@ -471,7 +469,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Editar Nome",
"New Name" => "Novo Nome",
"Format Cells" => "Formatar Células",
"Formula Builder" => "Construtor de Fórmulas",
"Find and Replace" => "Localizar e Substituir",
"Go To" => "Ir para",
"Insert Calculated Field" => "Inserir Campo Calculado",
+1 -5
View File
@@ -294,10 +294,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Mai multe formate de număr…",
"Financial" => "Financiare",
"Logical" => "Logice",
"Date &\nTime" => "Dată și\noră",
"Lookup &\nReference" => "Căutare și\nreferință",
"Math &\nTrig" => "Matematică și\ntrigonometrie",
"More\nFunctions" => "Mai multe\nfuncții",
"More Functions" => "Mai multe funcții",
"Page Layout|Orientation" => "Orientare",
"Display theme" => "Temă de afișare",
"Animations" => "Animații",
@@ -456,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Editare nume",
"New Name" => "Nume nou",
"Format Cells" => "Formatare celule",
"Formula Builder" => "Generator de formule",
"Find and Replace" => "Găsire și înlocuire",
"Go To" => "Salt la",
"Insert Calculated Field" => "Inserare câmp calculat",
+5 -4
View File
@@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "Длинная дата",
"More Number Formats…" => "Другие числовые форматы…",
"Financial" => "Финансовые",
"Recently Used" => "Последние",
"Logical" => "Логические",
"Date &\nTime" => "Дата и\nвремя",
"Lookup &\nReference" => "Поиск и\nссылки",
"Math &\nTrig" => "Математические и\nтригонометрические",
"More\nFunctions" => "Другие\nфункции",
"Date & Time" => "Дата и время",
"Lookup & Reference" => "Поиск и ссылки",
"Math & Trig" => "Математические и тригонометрические",
"More Functions" => "Другие функции",
"Page Layout|Orientation" => "Ориентация",
"Display theme" => "Тема оформления",
"Animations" => "Анимация",
+4 -4
View File
@@ -299,10 +299,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Još formata brojeva…",
"Financial" => "Finansijsko",
"Logical" => "Logičko",
"Date &\nTime" => "Datum i\nvreme",
"Lookup &\nReference" => "Pretraga i\nreference",
"Math &\nTrig" => "Matematika i\ntrigonometrija",
"More\nFunctions" => "Još\nfunkcija",
"Date & Time" => "Datum i vreme",
"Lookup & Reference" => "Pretraga i reference",
"Math & Trig" => "Matematika i trigonometrija",
"More Functions" => "Još funkcija",
"Sheet Options|View" => "Prikaz",
"Print" => "Štampaj",
"Print What" => "Šta štampati",
+4 -4
View File
@@ -403,10 +403,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Diğer Sayı Biçimleri…",
"Financial" => "Finansal",
"Logical" => "Mantıksal",
"Date &\nTime" => "Tarih ve\nSaat",
"Lookup &\nReference" => "Arama ve\nBaşvuru",
"Math &\nTrig" => "Matematik ve\nTrigonometri",
"More\nFunctions" => "Diğer\nİşlevler",
"Date & Time" => "Tarih ve Saat",
"Lookup & Reference" => "Arama ve Başvuru",
"Math & Trig" => "Matematik ve Trigonometri",
"More Functions" => "Diğer İşlevler",
"Page Layout|Orientation" => "Sayfa Yönü",
"Display theme" => "Görüntü teması",
"System" => "Sistem",
+4 -8
View File
@@ -289,10 +289,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Інші числові формати…",
"Financial" => "Фінансові",
"Logical" => "Логічні",
"Date &\nTime" => "Дата і\nчас",
"Lookup &\nReference" => "Пошук і\nпосилання",
"Math &\nTrig" => "Математичні і\nтригонометричні",
"More\nFunctions" => "Інші\nфункції",
"More Functions" => "Інші функції",
"Page Layout|Orientation" => "Орієнтація",
"Display theme" => "Тема оформлення",
"Animations" => "Анімація",
@@ -454,7 +451,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Edit Name" => "Edit Name",
"New Name" => "New Name",
"Format Cells" => "Format Cells",
"Formula Builder" => "Formula Builder",
"Find and Replace" => "Find and Replace",
"Go To" => "Go To",
"Insert Calculated Field" => "Insert Calculated Field",
@@ -711,9 +707,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Hidden" => "Hidden",
"Search:" => "Search:",
"e.g. lookup, average, date" => "e.g. lookup, average, date",
"Date & Time" => "Date & Time",
"Lookup & Reference" => "Lookup & Reference",
"Math & Trig" => "Math & Trig",
"Date & Time" => "Дата і час",
"Lookup & Reference" => "Пошук і посилання",
"Math & Trig" => "Математичні і тригонометричні",
"Statistical" => "Statistical",
"Engineering" => "Engineering",
"Database" => "Database",
+4 -4
View File
@@ -303,10 +303,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Định dạng số khác…",
"Financial" => "Tài chính",
"Logical" => "Logic",
"Date &\nTime" => "Ngày &\nGiờ",
"Lookup &\nReference" => "Tra cứu &\nTham chiếu",
"Math &\nTrig" => "Toán &\nLượng giác",
"More\nFunctions" => "Hàm\nkhác",
"Date & Time" => "Ngày & Giờ",
"Lookup & Reference" => "Tra cứu & Tham chiếu",
"Math & Trig" => "Toán & Lượng giác",
"More Functions" => "Hàm khác",
"Sheet Options|View" => "Xem",
"Print" => "In",
"Print What" => "Nội dung in",
+5 -4
View File
@@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Long Date" => "长日期",
"More Number Formats…" => "更多数字格式…",
"Financial" => "财务",
"Recently Used" => "最近使用的函数",
"Logical" => "逻辑",
"Date &\nTime" => "日期和\n时间",
"Lookup &\nReference" => "查找和\n引用",
"Math &\nTrig" => "数学和\n三角函数",
"More\nFunctions" => "更多\n函数",
"Date & Time" => "日期和时间",
"Lookup & Reference" => "查找和引用",
"Math & Trig" => "数学和三角函数",
"More Functions" => "更多函数",
"Page Layout|Orientation" => "纸张方向",
"Display theme" => "显示主题",
"Animations" => "动画",
+89 -2
View File
@@ -8,6 +8,27 @@ use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};
use crate::theme::{Cat, Tokens};
/// The glyph on a Function Library book ([`Icon::FnBook`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FnGlyph {
/// Recently Used: a star.
Star,
/// Financial: a stack of coins.
Coins,
/// Logical: a question mark.
Question,
/// Text: a letter.
Letter,
/// Date & Time: a clock face.
Clock,
/// Lookup & Reference: a magnifier.
Magnifier,
/// Math & Trig: theta.
Theta,
/// More Functions: three dots.
Dots,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Icon {
Paste,
@@ -121,6 +142,9 @@ pub enum Icon {
SheetList,
Book,
Folder,
/// A book with a coloured spine and a category glyph (Formulas › Function Library). The
/// spine and glyph take the accent colour of [`paint_accent`].
FnBook(FnGlyph),
/// Header & Footer: a page with its top / bottom band marked.
Header,
Footer,
@@ -170,6 +194,10 @@ impl G<'_> {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(PathShape::line(v, PathStroke::new(w * self.s.clamp(1.0, 2.0), c)));
}
fn closed(&self, pts: &[(f32, f32)], c: Color32, w: f32) {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(PathShape::closed_line(v, PathStroke::new(w * self.s.clamp(1.0, 2.0), c)));
}
fn stroke(&self, pts: &[(f32, f32)], c: Color32) {
self.line(pts, c, 1.15);
}
@@ -208,17 +236,20 @@ impl G<'_> {
}
/// The colours an icon draws with besides its main colour: the paper inside documents and cells,
/// and the category palette for its coloured parts.
/// the category palette for its coloured parts, and the accent a button asked for.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Palette {
paper: Color32,
cat: [Color32; 8],
recording: Color32,
/// The accent colour: the coloured part of an icon that has one (a book's spine and glyph),
/// otherwise the icon's main colour. `None` draws the icon as it is.
accent: Option<Color32>,
}
impl Palette {
fn of(t: &Tokens) -> Palette {
Palette { paper: t.paper, cat: t.cat, recording: t.recording }
Palette { paper: t.paper, cat: t.cat, recording: t.recording, accent: None }
}
fn cat(&self, c: Cat) -> Color32 {
@@ -242,11 +273,23 @@ pub fn fallback_for_symbol(c: char) -> Option<Icon> {
}
}
/// Whether an icon has a part of its own for the accent colour (otherwise the accent recolours
/// the whole icon).
fn has_accent_part(icon: Icon) -> bool {
matches!(icon, Icon::FnBook(_))
}
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
}
/// Draws `icon` like [`paint`], with an accent colour: the icon's coloured part when it has one
/// (the spine and glyph of [`Icon::FnBook`]), otherwise the whole icon. `None` is [`paint`].
pub fn paint_accent(p: &Painter, rect: Rect, icon: Icon, c: Color32, accent: Option<Color32>) {
paint_with(p, rect, icon, c, &Palette { accent, ..Palette::of(&Tokens::get(p.ctx())) });
}
/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette.
pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
paint_with(p, rect, icon, c, &Palette::of(&Tokens::light()));
@@ -257,6 +300,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
let side = rect.width().min(rect.height());
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let g = G { p, r, s: side / 16.0 };
let c = match pal.accent {
Some(a) if !has_accent_part(icon) => a,
_ => c,
};
let light = c.gamma_multiply(0.45);
let paper = pal.paper;
let blue = pal.cat(Cat::Blue);
@@ -690,6 +737,46 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
Icon::Folder => {
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow);
}
Icon::FnBook(glyph) => {
let spine = pal.accent.unwrap_or(c);
g.rect(3.0, 1.5, 10.5, 13.0, Some(paper), Some(c));
g.rect(3.0, 1.5, 2.5, 13.0, Some(spine), None);
let (x, y) = (9.5, 7.8);
match glyph {
FnGlyph::Star => {
let pts: Vec<(f32, f32)> = (0..10)
.map(|i| {
let a = std::f32::consts::PI * (i as f32 / 5.0 - 0.5);
let rad = if i % 2 == 0 { 3.4 } else { 1.4 };
(x + rad * a.cos(), y + rad * a.sin())
})
.collect();
g.closed(&pts, spine, 1.0);
}
FnGlyph::Coins => {
for k in 0..3 {
let top = y + 1.6 - k as f32 * 2.3;
g.p.rect_filled(Rect::from_min_max(g.pt(x - 2.8, top), g.pt(x + 2.8, top + 1.7)), 0.9 * g.s, spine);
}
}
FnGlyph::Question => g.text(x, y, "?", 8.5, spine, (true, false)),
FnGlyph::Letter => g.text(x, y, "A", 8.0, spine, (true, false)),
FnGlyph::Clock => {
g.circle(x, y, 3.2, None, Some(spine));
g.stroke(&[(x, y - 2.0), (x, y), (x + 1.6, y + 0.9)], spine);
}
FnGlyph::Magnifier => {
g.circle(x - 0.6, y - 0.6, 2.3, None, Some(spine));
g.line(&[(x + 1.0, y + 1.0), (x + 3.0, y + 3.0)], spine, 1.5);
}
FnGlyph::Theta => g.text(x, y, "θ", 8.5, spine, (false, false)),
FnGlyph::Dots => {
for dx in [-2.4, 0.0, 2.4] {
g.circle(x + dx, y, 0.9, Some(spine), None);
}
}
}
}
Icon::Header | Icon::Footer => {
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
@@ -92,6 +92,30 @@ pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> {
sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
}
/// True when `prefix` is the group level of a menu control: some of `target`'s sequences finish
/// one key below it (Borders' "HB" before HBA..HBW). The menu opens there, as Excel's does, so its
/// item letters show.
pub fn opens_menu(prefix: &str, target: &str) -> bool {
!prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
}
/// The badge for a menu item that runs `target` with `params`: its finish sequence one key below
/// the prefix. Items sharing one command (the Borders presets) are told apart by their params,
/// compared as JSON values, so key order and spacing don't matter.
pub fn item_letter_with(prefix: &str, target: &str, params: &serde_json::Value) -> Option<&'static str> {
SEQUENCES.iter().find_map(|(seq, entry)| match entry {
Entry::Finish(Action::CommandWith(id, p))
if *id == target
&& seq.len() == prefix.len() + 1
&& seq.starts_with(prefix)
&& serde_json::from_str::<serde_json::Value>(p).is_ok_and(|p| p == *params) =>
{
Some(*seq)
}
_ => None,
})
}
/// Badge sequences for a command control: every sequence for `target` that continues from the
/// current prefix (the painter strips the prefix, so the remaining letters show).
pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> {
@@ -197,6 +221,50 @@ mod tests {
assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item");
}
#[test]
fn menu_levels_and_item_letters_with_params() {
use serde_json::json;
assert!(opens_menu("HB", "home.borders"));
// Prefixes that are not the Borders menu level don't open it.
assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu");
assert!(!opens_menu("", "home.borders"));
assert!(!opens_menu("HBA", "home.borders"), "a finished sequence is not a menu level");
assert!(!opens_menu("HI", "home.borders"), "another group's level");
assert!(!opens_menu("HB", "home.fillColor"), "the Borders level is not another control's menu");
// Presets sharing home.borders are told apart by their params.
assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "all"})), Some("HBA"));
assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "bottom"})), Some("HBO"));
assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "nope"})), None);
assert_eq!(item_letter_with("H", "home.borders", &json!({"preset": "all"})), None);
// Params compare as JSON values: key order and spacing don't matter.
let parsed: serde_json::Value = serde_json::from_str("{ \"preset\" : \"all\" }").unwrap();
assert_eq!(item_letter_with("HB", "home.borders", &parsed), Some("HBA"));
assert_eq!(
json!({"b": 1, "a": 2}),
serde_json::from_str::<serde_json::Value>("{\"a\":2,\"b\":1}").unwrap(),
"maps compare regardless of order"
);
// Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J).
for preset in [
"bottom",
"top",
"left",
"right",
"none",
"all",
"outside",
"thickOutside",
"doubleBottom",
"thickBottom",
"topBottom",
"topThickBottom",
"topDoubleBottom",
"inside",
] {
assert!(item_letter_with("HB", "home.borders", &json!({"preset": preset})).is_some(), "{preset} has no letter");
}
}
#[test]
fn item_letter_is_one_level_deep() {
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
@@ -46,12 +46,13 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item
("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu
("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
("HBJ", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"inside\"}"))), // Excel: (none); Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB
("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item
("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item
("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item
("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item
@@ -242,6 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference)
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
("MR", Entry::Finish(Action::Control("formulas.recentlyUsed"))), // Excel: Recently Used Menu; opens the Recently Used menu
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
+17
View File
@@ -273,6 +273,16 @@
"excel": "Line Color",
"note": "pending: Line Color submenu"
},
{
"seq": "HBJ",
"kind": "command",
"target": "home.borders",
"params": {
"preset": "inside"
},
"excel": "(none)",
"note": "Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB"
},
{
"seq": "HBL",
"kind": "command",
@@ -1658,6 +1668,13 @@
"note": "group level: containment(More Functions)",
"target": "formulas.moreFunctions"
},
{
"seq": "MR",
"kind": "control",
"excel": "Recently Used Menu",
"note": "opens the Recently Used menu",
"target": "formulas.recentlyUsed"
},
{
"seq": "MS",
"kind": "continue",
+4
View File
@@ -32,6 +32,7 @@ mod floating_pictures;
pub mod fonts;
pub mod formula_bar;
mod formula_locale;
pub mod function_library;
pub mod grid;
pub mod i18n;
pub mod icons;
@@ -284,6 +285,9 @@ pub struct SheetApp {
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
pub allow_close: bool,
/// A quit question is waiting in the active window: [`doc_windows`] brings its native window
/// to the front, also when the app is hidden or the window minimized (a Dock quit).
pub bring_forward: bool,
/// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a
/// key tip, the command search or a menu, possibly while another tab was showing).
pub(crate) open_switch_windows: bool,
+28
View File
@@ -291,3 +291,31 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
}
v
}
#[cfg(test)]
mod tooltip_tests {
/// A ribbon tooltip names its control only; `widgets::tooltip_shortcut` adds the shortcut of
/// the platform it runs on. A Command-key glyph written into the text would show on Windows
/// and Linux, where the shortcut is a Ctrl or Alt one (AutoSum is Alt+=).
#[test]
fn tooltips_leave_the_shortcut_to_the_platform_lookup() {
let sources = [
include_str!("ribbon.rs"),
include_str!("ribbon/automate.rs"),
include_str!("ribbon/contextual.rs"),
include_str!("ribbon/controls.rs"),
include_str!("ribbon/data.rs"),
include_str!("ribbon/formulas.rs"),
include_str!("ribbon/galleries.rs"),
include_str!("ribbon/home.rs"),
include_str!("ribbon/insert.rs"),
include_str!("ribbon/page_layout.rs"),
include_str!("ribbon/review.rs"),
include_str!("ribbon/shortcuts.rs"),
include_str!("ribbon/title_bar.rs"),
include_str!("ribbon/view.rs"),
];
let mac_only = sources.iter().flat_map(|s| s.lines()).filter(|l| l.contains('\u{2318}')).collect::<Vec<_>>();
assert!(mac_only.is_empty(), "Command-key glyph in ribbon text: {mac_only:?}");
}
}
+86 -36
View File
@@ -1,6 +1,8 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, keytip, menu_items, popup_command, sep};
use super::home::autosum_menu;
use super::{Icon, SheetApp, Ui, big_button, big_split_button, json, keytip_table, small_button, small_split_button};
use super::{Cat, Icon, SheetApp, Tokens, Ui, big_button, big_split_button, json, keytip_table, small_button, small_split_button};
use crate::function_library;
use crate::icons::FnGlyph;
use crate::widgets::small_button_accent;
/// How many cells Formulas › Error Checking › Circular References lists.
const CIRCULAR_MENU_LIMIT: usize = 100;
@@ -19,6 +21,57 @@ fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> {
.collect()
}
/// What a Function Library button's menu lists.
#[derive(Clone, Copy, Debug)]
enum LibraryMenu {
Recent,
Category(&'static str),
More,
}
/// One Function Library category button: its label, its menu, the glyph and colour of its book,
/// and the control id its keytip sequence targets in `keytips.json`.
struct LibraryItem {
label: &'static str,
menu: LibraryMenu,
book: (FnGlyph, Cat),
keytip: &'static str,
}
impl LibraryItem {
const fn new(label: &'static str, menu: LibraryMenu, book: (FnGlyph, Cat), keytip: &'static str) -> LibraryItem {
LibraryItem { label, menu, book, keytip }
}
}
/// The Function Library categories after AutoSum, column by column (three rows each). Every
/// category has its own colour.
const LIBRARY: [LibraryItem; 8] = [
LibraryItem::new("Recently Used", LibraryMenu::Recent, (FnGlyph::Star, Cat::Amber), "formulas.recentlyUsed"),
LibraryItem::new("Financial", LibraryMenu::Category("Financial"), (FnGlyph::Coins, Cat::Green), "formulas.financial"),
LibraryItem::new("Logical", LibraryMenu::Category("Logical"), (FnGlyph::Question, Cat::Purple), "formulas.logical"),
LibraryItem::new("Text", LibraryMenu::Category("Text"), (FnGlyph::Letter, Cat::Blue), "formulas.text"),
LibraryItem::new("Date & Time", LibraryMenu::Category("DateTime"), (FnGlyph::Clock, Cat::Orange), "formulas.dateTime"),
LibraryItem::new("Lookup & Reference", LibraryMenu::Category("Lookup"), (FnGlyph::Magnifier, Cat::Teal), "formulas.lookup"),
LibraryItem::new("Math & Trig", LibraryMenu::Category("MathTrig"), (FnGlyph::Theta, Cat::Red), "formulas.mathTrig"),
LibraryItem::new("More Functions", LibraryMenu::More, (FnGlyph::Dots, Cat::Slate), "formulas.moreFunctions"),
];
/// One Function Library category button: a book in the category's colour that opens the
/// category's menu.
fn library_button(app: &mut SheetApp, ui: &mut Ui, item: &LibraryItem) {
let (glyph, cat) = item.book;
let accent = Some(Tokens::get(ui.ctx()).cat(cat));
let anchor = small_button_accent(ui, Icon::FnBook(glyph), accent, item.label, item.label, true);
control_keytip(app, ui, &anchor, item.keytip);
let forced = invoke(app, item.keytip);
egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| match item.menu {
LibraryMenu::Recent => function_library::recent_menu(app, ui),
LibraryMenu::Category(id) => function_library::category_menu(app, ui, id),
LibraryMenu::More => function_library::more_menu(app, ui),
});
}
/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula
/// language, descriptions in the interface language), defined names, auditing and calculation.
pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
@@ -27,42 +80,21 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
if __k.clicked() {
app.open_dialog("insertFunction", json!({}));
}
let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
control_keytip(app, ui, &sa, "formulas.autoSum");
if s.clicked() {
// Function Library: AutoSum as a big split button, then the categories in columns of three
// labelled small buttons, as Excel lays the group out when the window is not wide enough for
// a big button per category.
let (sum, sum_arrow) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
control_keytip(app, ui, &sum_arrow, "formulas.autoSum");
if sum.clicked() {
act(app, "formulas.autoSum", json!({}));
}
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
for (label, cat, cid) in [
("Financial", "Financial", "formulas.financial"),
("Logical", "Logical", "formulas.logical"),
("Text", "Text", "formulas.text"),
("Date &\nTime", "DateTime", "formulas.dateTime"),
("Lookup &\nReference", "Lookup", "formulas.lookup"),
("Math &\nTrig", "MathTrig", "formulas.mathTrig"),
("More\nFunctions", "Statistical", "formulas.moreFunctions"),
] {
let r = big_button(ui, Icon::Book, label, label, true);
control_keytip(app, ui, &r, cid);
let forced_c = invoke(app, cid);
egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| {
ui.set_max_height(420.0);
egui::ScrollArea::vertical().show(ui, |ui| {
for f in gridcraft_engine::cmd::formulas::function_list() {
if f["category"].as_str() == Some(cat)
&& let Some(n) = f["name"].as_str()
{
let locale = app.ui.language.formula_locale();
let name =
app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n);
let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default();
if ui.button(name).on_hover_text(desc).clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": n}));
ui.close();
}
}
}
});
let forced = invoke(app, "formulas.autoSum");
egui::Popup::menu(&sum_arrow).open_memory(popup_command(forced, sum_arrow.clicked())).show(|ui| function_library::auto_sum_menu(app, ui));
for column in LIBRARY.chunks(3) {
ui.vertical(|ui| {
for item in column {
library_button(app, ui, item);
}
});
}
sep(ui);
@@ -200,3 +232,21 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
}
});
}
#[cfg(test)]
mod function_library_tests {
use super::*;
#[test]
fn every_function_library_book_has_its_own_glyph_and_colour() {
let books: Vec<(FnGlyph, Cat)> = LIBRARY.iter().map(|i| i.book).collect();
assert_eq!(books.len(), 8, "every category is a coloured book");
for (i, (glyph, cat)) in books.iter().enumerate() {
assert!(books.iter().skip(i + 1).all(|(g, c)| g != glyph && c != cat), "{glyph:?} / {cat:?} is used twice");
}
assert!(matches!(LIBRARY.first().map(|i| i.menu), Some(LibraryMenu::Recent)));
for item in &LIBRARY {
assert!(!keytip_table::sequences_for(item.keytip).is_empty(), "{} has a keytip sequence", item.label);
}
}
}
+19 -22
View File
@@ -5,7 +5,7 @@ use super::controls::{
use super::galleries::{cell_style_gallery, table_gallery};
use super::{
HAlign, Icon, Key, Modifiers, Rect, SheetApp, Style, Tokens, Ui, VAlign, big_button, color_palette, icon_button, json, keytip_table,
small_button, split_button, theme, toggle_button, vec2,
small_button, small_split_button, split_button, theme, toggle_button, vec2,
};
/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol.
@@ -158,7 +158,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
act(app, "home.borders", json!({"preset": app.grid.last_border.clone()}));
}
ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets"));
egui::Popup::menu(&ba).show(|ui| {
// Alt, H, B opens the menu with each item's letter, as Excel's does (#2964); a letter
// or Esc changes the prefix, which closes it again (`ribbon_keys`).
let prefix = app.keytips.prefix().unwrap_or_default().to_owned();
let keyed = keytip_table::opens_menu(&prefix, "home.borders");
egui::Popup::menu(&ba).open_memory(popup_command(keyed, ba.clicked())).show(|ui| {
for (label, preset) in [
("Bottom Border", "bottom"),
("Top Border", "top"),
@@ -175,13 +179,21 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
("Top and Double Bottom Border", "topDoubleBottom"),
("Inside Borders", "inside"),
] {
if crate::border_preview::preset_button(ui, label, preset).clicked() {
let item = crate::border_preview::preset_button(ui, label, preset);
if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset})) {
keytip(app, ui, sequence, egui::pos2(item.rect.right() - 10.0, item.rect.center().y));
}
if item.clicked() {
app.grid.last_border = preset.to_string();
act(app, "home.borders", json!({"preset": preset}));
}
}
ui.separator();
if ui.button("More Borders…").clicked() {
let more = ui.button("More Borders…");
if let Some(sequence) = keytip_table::item_letter(&prefix, "dialog:formatCells") {
keytip(app, ui, sequence, egui::pos2(more.rect.right() - 10.0, more.rect.center().y));
}
if more.clicked() {
app.open_dialog("formatCells", json!({"tab": "Border"}));
}
});
@@ -292,12 +304,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
if __k.clicked() {
act(app, "home.increaseIndent", json!({}));
}
let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center");
let (m, ma) = small_split_button(ui, Icon::Merge, "Merge & Center", "Merge & Center");
control_keytip(app, ui, &ma, "home.mergeCenter");
if m {
if m.clicked() {
act(app, "home.mergeCenter", json!({}));
}
ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5)));
egui::Popup::menu(&ma).show(|ui| {
menu_items(
app,
@@ -469,7 +480,7 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
if s {
act(app, "formulas.autoSum", json!({}));
}
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
egui::Popup::menu(&sa).show(|ui| crate::function_library::auto_sum_menu(app, ui));
});
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
for sequence in keytip_table::group_extension(
@@ -562,20 +573,6 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
});
}
/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
/// Function dialog.
pub(super) fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
if ui.button(f).clicked() {
act(app, "formulas.autoSum", json!({"function": f}));
}
}
ui.separator();
if ui.button("More Functions…").clicked() {
app.open_dialog("insertFunction", json!({}));
}
}
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let name = st.font.name.clone();
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
+19 -10
View File
@@ -2,6 +2,7 @@
//! offers the new release: Update and Restart (direct self-update), Download or Later. The flow
//! itself is `craft_update::updater::Updater`.
use craft_update::CheckResultResponse;
use craft_update::updater::{Action, Status, Updater};
use crate::SheetApp;
@@ -14,11 +15,13 @@ pub(crate) struct Runtime {
startup: bool,
/// Help ▸ Check for Updates… was chosen; the next `tick` starts it.
manual: bool,
/// The result dialog of a requested check is showing.
result_open: bool,
}
impl Default for Runtime {
fn default() -> Self {
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false }
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false, result_open: false }
}
}
@@ -35,19 +38,14 @@ pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) {
let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.manual) {
rt.updater.check_now(ctx);
rt.result_open = true;
} else if rt.startup {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
let lang = app.ui.language;
let outcome = match rt.updater.status() {
Status::UpToDate => Some(lang.tr("GridCraft {version} is up to date.").replace("{version}", VERSION)),
Status::Failed(e) => Some(lang.tr("Could not check for updates: {error}").replace("{error}", e)),
Status::Idle | Status::Checking => None,
};
if let Some(message) = outcome {
rt.updater.dismiss_status();
app.message = Some(("GridCraft".into(), message));
// A requested check that found a release leaves the status idle and the prompt opens.
if matches!(rt.updater.status(), Status::Idle) {
rt.result_open = false;
}
}
@@ -65,4 +63,15 @@ pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) {
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
if app.updates_rt.result_open {
let rt = &mut app.updates_rt;
match craft_update::CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
CheckResultResponse::Close => {
rt.updater.dismiss_status();
rt.result_open = false;
}
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
CheckResultResponse::None => {}
}
}
}
+7 -1
View File
@@ -183,6 +183,12 @@ fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>],
/// Small ribbon button: 16px icon with optional label to the right.
pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response {
small_button_accent(ui, icon, None, label, tip, dropdown)
}
/// Small ribbon button whose icon takes an accent colour (see [`icons::paint_accent`]); `None`
/// draws it like [`small_button`]. A labelled button reports its label to accessibility.
pub fn small_button_accent(ui: &mut Ui, icon: Icon, accent: Option<Color32>, label: &str, tip: &str, dropdown: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(12.5);
let label = tr(ui, label);
@@ -194,7 +200,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b
} else if resp.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text);
icons::paint_accent(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text, accent);
if !label.is_empty() {
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text);
}
+9 -3
View File
@@ -26,15 +26,19 @@ pub struct WindowUi {
selected_chart: Option<u32>,
tab_run: Option<(u64, usize, u32, CellRef)>,
open_switch_windows: bool,
/// The ribbon tab this window shows. A contextual tab depends on this window's selection, so
/// another window's frame must not reset it.
ribbon_tab: String,
/// The status bar's Ready/Enter/Edit, which follows the window's cell draft.
mode: gridcraft_engine::Mode,
}
impl WindowUi {
/// A new window's state. The last border, fill and font colour picked carry over, as they are
/// the user's recent choices rather than the window's.
fn fresh(like: &grid::GridState) -> WindowUi {
/// the user's recent choices rather than the window's; so does the ribbon tab.
fn fresh(like: &grid::GridState, ribbon_tab: &str) -> WindowUi {
WindowUi {
ribbon_tab: ribbon_tab.to_string(),
grid: grid::GridState {
last_border: like.last_border.clone(),
last_fill: like.last_fill.clone(),
@@ -64,6 +68,7 @@ impl SheetApp {
selected_chart,
tab_run,
open_switch_windows,
ribbon_tab,
mode,
} = w;
std::mem::swap(&mut self.editor, editor);
@@ -79,6 +84,7 @@ impl SheetApp {
std::mem::swap(&mut self.selected_chart, selected_chart);
std::mem::swap(&mut self.tab_run, tab_run);
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab);
std::mem::swap(&mut self.session.mode, mode);
}
@@ -100,7 +106,7 @@ impl SheetApp {
self.ui_window = active;
return;
};
let mut outgoing = WindowUi::fresh(&self.grid);
let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab);
self.swap_window_ui(&mut outgoing);
if self.session.window(previous).is_some() {
self.parked.insert(previous, outgoing);
+17
View File
@@ -232,6 +232,23 @@ fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() {
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}");
}
#[test]
fn a_quit_question_brings_its_window_to_the_front_from_the_background() {
// A Dock quit reaches the app while it is behind other apps, hidden or minimized.
let mut desk = Desk::new(true);
desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
desk.frames(3);
let w = desk.window_of(0);
let sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
for cmd in [ViewportCommand::Visible(true), ViewportCommand::Minimized(false), ViewportCommand::Focus] {
assert!(sent_to(&sent, desk.viewport(w), &cmd), "{cmd:?} missing: {sent:?}");
}
let sent = desk.frames(2);
assert!(!sent_to(&sent, desk.viewport(w), &ViewportCommand::Focus), "only once: {sent:?}");
}
#[test]
fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() {
let mut desk = Desk::new(true);
+5 -2
View File
@@ -152,8 +152,11 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() {
let (mut h, id) = fixture();
let inside = corner(&h, id) - vec2(100.0, 80.0);
right_click(&mut h, inside, vec2(0.0, 0.0));
// Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last.
h.get_all_by_role_and_label(Role::Button, "Delete").last().unwrap().click();
// Home › Cells has a Delete button too: take the one in the chart's menu, by the click.
h.query_all_by_label("Delete")
.min_by(|a, b| a.rect().center().distance(inside).total_cmp(&b.rect().center().distance(inside)))
.expect("the chart menu has Delete")
.click();
h.run_steps(3);
assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty());
assert!(h.state().grid.object_menu.is_none());
@@ -74,3 +74,49 @@ fn an_ordinary_tab_stays_put_in_a_session() {
h.run_steps(3);
assert_eq!(h.state().ui.ribbon_tab, "Data");
}
#[test]
fn a_second_window_does_not_reset_the_first_windows_table_design_tab() {
use egui::{Event, PointerButton, Shape};
let mut h = harness(session_with_table());
let first = h.state().session.active_window().unwrap();
// A second window on the same workbook, with its active cell outside the table.
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().run("selection.set", json!({"cell": "D10"})).unwrap();
h.state_mut().run("window.activate", json!({"window": first})).unwrap();
h.run_steps(2);
let tab_center = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == "Table Design" => Some(t.pos + t.galley.size() / 2.0),
_ => None,
})
.expect("the Table Design tab is painted while a cell is in the table");
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton {
pos: tab_center,
button: PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(1);
}
h.run_steps(2);
assert_eq!(h.state().ui.ribbon_tab, "Table Design");
// DocWindows draws every window each frame. The second window's cell is outside the table,
// so its frame falls back to Home; the first window's Table Design tab must survive it.
h.state_mut().show_window(second);
h.run_steps(2);
assert_eq!(h.state().ui.ribbon_tab, "Home", "the second window shows Home for its own selection");
h.state_mut().enter_window(first);
h.run_steps(2);
assert_eq!(h.state().session.active_window(), Some(first));
assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the first window's tab survives the second window's frame");
let d = h.state().session.active().unwrap();
assert_eq!(d.selection.active.a1(), "A2", "the first window still selects a cell in the table");
assert!(d.wb.active().unwrap().table_at(d.selection.active).is_some(), "the table is still there");
}
+68
View File
@@ -62,6 +62,74 @@ fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() {
assert!(h.state().ui.ribbon_collapsed);
}
/// Esc first cancels the focused File-page field, then closes File on the next press.
#[test]
fn escape_leaves_file_fields_before_closing_the_view() {
use egui::accesskit::Role;
for (page_id, label) in [
("info", "Title"),
("info", "Subject"),
("info", "Author"),
("info", "Company"),
("info", "Keywords"),
("info", "Description"),
("options", "Maximum Iterations:"),
("options", "Maximum Change:"),
("print", "Copies"),
] {
let services = Services {
printers: Some(Box::new(|| {
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Vec::new()).unwrap();
rx
})),
..Default::default()
};
let mut session = Session::new();
session.new_workbook();
let mut h = crate::common::harness_with(
crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)),
crate::common::prepare(SheetApp::new(session, services)),
4,
);
h.state_mut().run("formulas.calculationOptions", serde_json::json!({"iterative": true})).unwrap();
h.state_mut().run("ui.backstage", serde_json::json!({"page": page_id})).unwrap();
h.run_steps(3);
let role = if page_id == "print" { Role::SpinButton } else { Role::TextInput };
h.get_by_role_and_label(role, label).click();
h.run_steps(2);
assert!(h.ctx.text_edit_focused(), "{label} has the caret");
let original = h.get_by_role_and_label(role, label).value();
let revision = h.state().session.active().unwrap().revision;
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(1);
h.input_mut().events.push(egui::Event::Text("7".into()));
h.run_steps(2);
if page_id == "print" {
assert_eq!(h.state().printing.settings.copies, 7, "Copies applies the entered value while editing");
} else {
assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7"));
}
let copies = h.state().printing.settings.copies;
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(page(&h), Some(page_id), "Esc in {label} keeps File open");
assert!(!h.ctx.text_edit_focused(), "Esc leaves {label}");
assert_eq!(h.state().session.active().unwrap().revision, revision, "Esc does not commit {label}");
if page_id == "print" {
assert_eq!(h.state().printing.settings.copies, copies, "leaving Copies preserves its existing live value");
} else {
assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value(), original, "Esc discards the {label} draft");
}
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(page(&h), None, "the next Esc returns to the sheet");
}
}
#[test]
fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() {
let downloads = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
+332
View File
@@ -0,0 +1,332 @@
//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions
//! cascade, the AutoSum big split button, the Recently Used keytip and list, and the Insert Function
//! dialog's categories.
use egui::os::OperatingSystem;
use egui::{Event, Key, Modifiers, vec2};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn formulas_tab(width: f32) -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut app = crate::common::app(session);
app.ui.ribbon_tab = "Formulas".into();
crate::common::harness_with(crate::common::builder().with_os(OperatingSystem::Nix).with_size(vec2(width, 760.0)), app, 4)
}
fn editor_text(h: &Harness) -> Option<String> {
h.state().editor.as_ref().map(|e| e.text.clone())
}
fn input(h: &Harness, cell: &str) -> String {
let d = h.state().session.active().unwrap();
d.wb.active().unwrap().input_text(CellRef::parse(cell).unwrap())
}
/// Clicks a button by its name (a tooltip also names an icon-only button, such as the formula
/// bar's fx, which is not a button to accessibility).
fn click(h: &mut Harness, label: &str) {
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
h.run_steps(3);
}
/// Clicks a menu row through accessibility, which reaches rows scrolled out of a long menu.
fn pick(h: &mut Harness, label: &str) {
h.get_by_label(label).click_accesskit();
h.run_steps(3);
}
fn close_menus(h: &mut Harness) {
h.key_press(Key::Escape);
h.run_steps(2);
h.state_mut().editor = None;
h.run_steps(2);
}
#[test]
fn autosum_is_a_big_split_button_then_the_categories_in_columns_of_three() {
let h = formulas_tab(1440.0);
let columns: [&[&str]; 3] =
[&["Recently Used", "Financial", "Logical"], &["Text", "Date & Time", "Lookup & Reference"], &["Math & Trig", "More Functions"]];
let rect = |l: &str| h.get_by_role_and_label(egui::accesskit::Role::Button, l).rect();
for column in columns {
for pair in column.windows(2) {
let (a, b) = (rect(pair[0]), rect(pair[1]));
assert!((a.left() - b.left()).abs() < 0.5, "{pair:?} share a left edge");
assert!(a.bottom() <= b.top() + 0.5, "{pair:?} stack in order");
}
}
for pair in columns.windows(2) {
assert!(rect(pair[0][0]).right() <= rect(pair[1][0]).left(), "columns run left to right");
}
// Insert Function and AutoSum are big buttons, to the left of the columns.
let (insert, sum) = (rect("Insert Function"), rect("AutoSum"));
assert!(insert.right() <= sum.left() && sum.right() <= rect("Recently Used").left());
assert!(sum.top() <= rect("Recently Used").top() + 0.5, "AutoSum starts at the top of the group");
assert!(h.get_by_label("AutoSum options").rect().bottom() > rect("Financial").bottom(), "its arrow part reaches below the second row");
// A full column stands where the tab's other columns of three small buttons do (Formula
// Auditing's), so it fits the ribbon in any font.
let (top, bottom) = (rect("Show Formulas").top(), rect("Evaluate Formula").bottom());
for column in &columns[..2] {
let (t, b) = (rect(column[0]).top(), rect(column[2]).bottom());
assert!((t - top).abs() < 0.5 && (b - bottom).abs() < 0.5, "{column:?} spans {t}..{b}, Formula Auditing {top}..{bottom}");
}
}
#[test]
fn each_category_menu_lists_its_functions_then_insert_function() {
let mut h = formulas_tab(1440.0);
// Recently Used lists the functions of formulas entered.
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap();
h.run_steps(2);
for (button, function) in [
("Recently Used", "SUM"),
("Financial", "PMT"),
("Logical", "IF"),
("Text", "CONCAT"),
("Date & Time", "TODAY"),
("Lookup & Reference", "XLOOKUP"),
("Math & Trig", "ROUND"),
] {
click(&mut h, button);
h.get_by_label("Insert Function…");
pick(&mut h, function);
assert_eq!(editor_text(&h).as_deref(), Some(format!("={function}(").as_str()), "{button} › {function} starts the formula");
close_menus(&mut h);
}
// The menu's last row opens Insert Function on the menu's category.
click(&mut h, "Date & Time");
click(&mut h, "Insert Function…");
let d = h.state().dialog.as_ref().expect("Insert Function opens");
assert_eq!(d.name(), "insertFunction");
assert_eq!(d.values.get("category"), Some(&json!("Date & Time")));
}
#[test]
fn more_functions_cascades_into_the_remaining_categories() {
let mut h = formulas_tab(1440.0);
click(&mut h, "More Functions");
for c in ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"] {
h.get_by_label(c);
}
click(&mut h, "Engineering");
h.get_by_label("Insert Function…");
pick(&mut h, "BIN2DEC");
assert_eq!(editor_text(&h).as_deref(), Some("=BIN2DEC("));
close_menus(&mut h);
click(&mut h, "More Functions");
click(&mut h, "Statistical");
pick(&mut h, "Insert Function…");
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("category")), Some(&json!("Statistical")));
}
#[test]
fn autosum_runs_sum_and_its_arrow_opens_the_shared_menu() {
let mut h = formulas_tab(1440.0);
{
let s = &mut h.state_mut().session;
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 4], [2, 5], [3, 6]]})).unwrap();
s.execute("selection.set", json!({"cell": "A4"})).unwrap();
}
h.run_steps(2);
click(&mut h, "AutoSum");
assert_eq!(input(&h, "A4"), "=SUM(A1:A3)");
h.state_mut().session.execute("selection.set", json!({"cell": "B4"})).unwrap();
click(&mut h, "AutoSum options");
for item in ["Sum", "Average", "Max", "Min", "More Functions…"] {
h.get_by_label(item);
}
click(&mut h, "Count Numbers");
assert_eq!(input(&h, "B4"), "=COUNT(B1:B3)");
click(&mut h, "AutoSum options");
click(&mut h, "More Functions…");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("insertFunction"));
}
fn key_event(key: Key, pressed: bool) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
}
#[test]
fn the_recently_used_keytip_opens_its_menu() {
let mut h = formulas_tab(1440.0);
// Recently Used lists the functions of formulas entered.
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap();
h.run_steps(2);
h.input_mut().events.extend([key_event(Key::F10, true), key_event(Key::F10, false)]);
h.run_steps(2);
for k in [Key::M, Key::R] {
h.input_mut().events.extend([key_event(k, true), Event::Text(k.name().to_ascii_lowercase()), key_event(k, false)]);
h.run_steps(3);
}
assert_eq!(h.state().keytips.prefix(), None, "MR finishes the sequence");
h.get_by_label("Insert Function…");
pick(&mut h, "AVERAGE");
assert_eq!(editor_text(&h).as_deref(), Some("=AVERAGE("));
}
#[test]
fn recently_used_lists_the_engines_list_newest_first() {
let mut h = formulas_tab(1440.0);
// The ribbon and the control channel go through app.run; MCP and scripts through the session.
// The engine keeps the list (see the engine's recently_used tests); the menu shows it.
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap();
h.state_mut().session.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap();
h.run_steps(2);
assert_eq!(&h.state().session.prefs.recent_functions[..4], ["FV", "IRR", "ROUND", "NPV"]);
click(&mut h, "Recently Used");
let top = |h: &Harness, name: &str| h.get_by_label(name).rect().top();
assert!(top(&h, "FV") < top(&h, "IRR") && top(&h, "IRR") < top(&h, "ROUND") && top(&h, "ROUND") < top(&h, "NPV"));
}
/// Opens the Insert Function category list (the dialog's only combo box).
fn open_categories(h: &mut Harness) {
h.get_by_role(egui::accesskit::Role::ComboBox).click();
h.run_steps(3);
}
/// Picks a category from the open list (the ribbon has buttons with the same names).
fn choose(h: &mut Harness, category: &str) {
h.get_all_by_label(category).last().expect("the category is listed").click();
h.run_steps(3);
}
fn row(name: &str, category: &str) -> String {
format!("{name} ({category})")
}
#[test]
fn insert_function_has_its_title_and_a_working_category_list() {
let mut h = formulas_tab(1440.0);
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap();
h.run_steps(2);
click(&mut h, "Insert Function");
let d = h.state().dialog.as_ref().expect("Insert Function opens");
assert_eq!(d.title, "Insert Function");
// Most Recently Used comes first, newest first.
let npv = h.get_by_label(&row("NPV", "Financial")).rect();
assert!(npv.top() < h.get_by_label(&row("SUM", "Math & Trig")).rect().top());
assert!(h.query_by_label(&row("PMT", "Financial")).is_some(), "the default list follows");
// Pick a category from the list.
open_categories(&mut h);
choose(&mut h, "Date & Time");
assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some());
assert!(h.query_by_label(&row("NPV", "Financial")).is_none());
// All lists everything; a category given by its engine id works too (control channel).
open_categories(&mut h);
choose(&mut h, "All");
assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some() && h.query_by_label(&row("NPV", "Financial")).is_some());
h.state_mut().dialog.as_mut().unwrap().values.insert("category".into(), json!("MathTrig"));
h.run_steps(2);
assert!(h.query_by_label(&row("ROUND", "Math & Trig")).is_some() && h.query_by_label(&row("TODAY", "Date & Time")).is_none());
// A search looks through every category.
h.state_mut().dialog.as_mut().unwrap().search = "lookup".into();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("category"), Some(&json!("All")));
assert!(h.query_by_label(&row("XLOOKUP", "Lookup & Reference")).is_some());
// OK starts the formula. The function counts as recently used once the formula is entered,
// not before (the edit may still be cancelled, or there may be no cell to edit).
pick(&mut h, &row("XLOOKUP", "Lookup & Reference"));
click(&mut h, " OK ");
assert_eq!(editor_text(&h).as_deref(), Some("=XLOOKUP("));
assert_ne!(h.state().session.prefs.recent_functions[0], "XLOOKUP");
}
#[test]
fn insert_function_lets_tab_reach_the_category_list() {
use egui::accesskit::Role;
let mut h = formulas_tab(1440.0);
click(&mut h, "Insert Function");
h.run_steps(3);
let search_focused = |h: &Harness| h.get_all_by_role(Role::TextInput).any(|n| n.is_focused());
assert!(search_focused(&h), "the search box takes the focus when the dialog opens");
h.input_mut().events.push(Event::Text("npv".into()));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.search.as_str()), Some("npv"));
// Tab moves on to the category list, and the search box does not take the focus back.
h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]);
h.run_steps(5);
assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list");
assert!(!search_focused(&h));
// The list opens from the keyboard (its Date & Time joins the ribbon's).
let listed = |h: &Harness| h.get_all_by_label("Date & Time").count();
let before = listed(&h);
h.input_mut().events.extend([key_event(Key::Space, true), key_event(Key::Space, false)]);
h.run_steps(3);
assert_eq!(listed(&h), before + 1, "the list is open");
}
#[test]
fn enter_on_the_category_list_does_not_confirm_insert_function() {
use egui::accesskit::Role;
let mut h = formulas_tab(1440.0);
click(&mut h, "Insert Function");
h.run_steps(3);
let open = |h: &Harness| h.state().dialog.as_ref().is_some_and(|d| d.name == "insertFunction");
let listed = |h: &Harness| h.get_all_by_label("Date & Time").count();
let closed = listed(&h);
h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]);
h.run_steps(5);
assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list");
// Enter on the focused list opens it, as Space does; the dialog stays open and nothing is
// inserted.
h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
h.run_steps(3);
assert!(open(&h), "Enter on the category list leaves the dialog open");
assert_eq!(editor_text(&h), None, "no function is inserted");
assert_eq!(listed(&h), closed + 1, "Enter opens the list");
// Enter again, with the list open, closes it and still does not confirm.
h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
h.run_steps(3);
assert!(open(&h), "Enter in the open list leaves the dialog open");
assert_eq!(editor_text(&h), None);
// Back in the search box (Shift+Tab), Enter confirms as before.
let shift_tab = |pressed| Event::Key { key: Key::Tab, physical_key: None, pressed, repeat: false, modifiers: Modifiers::SHIFT };
h.input_mut().events.extend([shift_tab(true), shift_tab(false)]);
h.run_steps(3);
assert!(!h.get_by_role(Role::ComboBox).is_focused(), "Shift+Tab leaves the category list");
h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]);
h.run_steps(3);
assert!(!open(&h), "Enter in the search box confirms");
assert!(editor_text(&h).is_some_and(|t| t.starts_with('=') && t.ends_with('(')), "{:?}", editor_text(&h));
}
/// The text shapes the last frame painted, with their left edge and width.
fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> {
let mut out = Vec::new();
for clipped in &h.output().shapes {
if let egui::Shape::Text(t) = &clipped.shape {
out.push((t.galley.text().to_string(), t.pos.x, t.galley.size().x));
}
}
out
}
#[test]
fn insert_function_keeps_the_category_column_clear_of_long_names() {
let mut h = formulas_tab(1440.0);
// A wide interface font (as on some Windows and Linux desktops, or a long formula language)
// makes every name wider than the dialog was laid out for.
h.ctx.all_styles_mut(|s| {
s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(20.0));
});
click(&mut h, "Insert Function");
// Some of the longest function names in the catalog, and a short one, in one list (the
// signature under the list starts with a name too, and is not a row).
h.state_mut().dialog.as_mut().unwrap().search = "forecast".into();
h.run_steps(3);
let shapes = painted_text(&h);
let names: Vec<&(String, f32, f32)> = shapes.iter().filter(|(t, _, _)| t.starts_with("FORECAST") && !t.contains('(')).collect();
let column = shapes.iter().filter(|(t, _, _)| t == "Statistical").map(|(_, x, _)| *x).fold(f32::INFINITY, f32::min);
assert!(names.iter().any(|(t, _, _)| t == "FORECAST.ETS.SEASONALITY"), "the long name is listed: {shapes:?}");
for (name, x, w) in names {
assert!(x + w < column, "{name} ends at {} and the category column starts at {column}", x + w);
}
}
+16 -1
View File
@@ -338,6 +338,13 @@ fn keytip_hbi_() {
assert!(h.state().session.journal.is_empty(), "HBI must not journal");
}
#[test]
fn keytip_hbj_() {
let mut h = harness(blank());
sequence(&mut h, "HBJ", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBJ must journal home.borders");
}
#[test]
fn keytip_hbl_() {
let mut h = harness(blank());
@@ -1764,6 +1771,14 @@ fn keytip_mq_() {
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_mr_() {
let mut h = harness(blank());
sequence(&mut h, "MR", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "MR must route to its control without an error box");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_ms_() {
let mut h = harness(blank());
@@ -2754,7 +2769,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
const EXPECTED: usize = 365;
const EXPECTED: usize = 367;
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
}
+3
View File
@@ -47,6 +47,7 @@ mod formula_locale;
mod formula_point_headers;
mod formula_popup;
mod freeze_handles;
mod function_library;
mod go_to_dialog;
mod header_autofit;
mod header_column_drag;
@@ -63,6 +64,7 @@ mod keytips_generated;
mod macro_editor;
mod macros_ui;
mod menu_hover;
mod merge_center_label;
mod merged_cells;
mod more_colors;
mod native_print;
@@ -79,6 +81,7 @@ mod pivot_show_details;
mod pivot_show_details_menu;
mod popup_dismissal;
mod protect_password;
mod protected_cell;
mod ribbon_layout;
mod right_to_left;
mod scroll_position;
@@ -0,0 +1,51 @@
//! Home › Merge & Center is a split button: clicking its text label runs `home.mergeCenter`, like
//! its icon does.
use egui::{Event, Shape, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::i18n::Language;
use serde_json::json;
#[test]
fn clicking_the_label_merges_and_centers() {
for language in [Language::En, Language::De] {
click_label(language);
}
}
fn click_label(language: Language) {
let label = language.tr("Merge & Center");
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
{
let mut app = crate::common::app(s);
app.ui.language = language;
app
},
);
h.run_steps(4);
let at = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == label => Some(t.pos + t.galley.size() / 2.0),
_ => None,
})
.unwrap_or_else(|| panic!("the Home tab shows {label}"));
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(2);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
}
h.run_steps(2);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCenter"), "{label}");
}
+70
View File
@@ -0,0 +1,70 @@
//! Typing into a locked cell of a protected sheet warns before an editor opens, and the warning
//! leaves Undo, Save and typing in unlocked cells working (#2945).
use egui::{Event, Key, Modifiers};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
/// A protected sheet whose C2 is unlocked and holds "keep"; A1 is locked and active.
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap();
s.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
crate::support::harness(s, 4)
}
fn key(h: &mut Harness, k: Key) {
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
/// Types text the way a keyboard does (into whatever has focus).
fn type_text(h: &mut Harness, text: &str) {
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(2);
}
fn value(h: &Harness, a1: &str) -> Value {
let at = CellRef::parse(a1).unwrap();
h.state().session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default()
}
#[test]
fn typing_in_a_locked_cell_warns_without_a_stuck_draft() {
let mut h = harness();
let undo = h.state().session.active().unwrap().undo.len();
type_text(&mut h, "locked");
assert!(h.state().editor.is_none(), "no editor opened on the locked cell");
assert_eq!(h.state().message.as_ref().map(|(_, m)| m.as_str()), Some(PROTECTED));
assert_eq!(h.state().session.active().unwrap().undo.len(), undo, "the refused edit added no undo step");
assert_eq!(value(&h, "A1"), Value::Empty);
// No draft exists, so Save is not blocked while the warning is up.
assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
// Enter dismisses the warning and the keyboard goes back to the grid.
key(&mut h, Key::Enter);
assert!(h.state().message.is_none(), "Enter dismisses the warning");
// An unlocked cell on the same sheet still edits, undoes and saves.
h.state_mut().run("selection.set", json!({"cell": "C2"})).unwrap();
h.run_steps(1);
type_text(&mut h, "typed");
assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("typed"));
key(&mut h, Key::Enter);
assert_eq!(value(&h, "C2"), Value::text("typed"));
assert!(h.state_mut().run("edit.undo", json!({})).is_ok());
assert_eq!(value(&h, "C2"), Value::text("keep"), "Undo of the unlocked cell's edit still works");
assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
}
+4 -4
View File
@@ -135,15 +135,15 @@ fn big_split_button_separates_the_default_action_from_the_menu() {
}
#[test]
fn formulas_autosum_runs_on_the_icon_and_opens_its_menu_on_the_label() {
fn formulas_autosum_runs_sum_and_its_arrow_opens_the_menu() {
let mut h = app_at(1600.0, "Formulas");
h.get_by_label("AutoSum").click();
h.run_steps(2);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the icon runs AutoSum");
assert!(h.query_by_label("AVERAGE").is_none());
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the button runs AutoSum");
assert!(h.query_by_label("Average").is_none());
h.get_by_label("AutoSum options").click();
h.run_steps(2);
h.get_by_label("AVERAGE").click();
h.get_by_label("Average").click();
h.run_steps(2);
let last = h.state().session.journal.iter().rev().find(|(id, _)| id == "formulas.autoSum").map(|(_, p)| p.clone());
assert_eq!(last.and_then(|p| p.get("function").and_then(|f| f.as_str().map(str::to_string))).as_deref(), Some("AVERAGE"));
+35
View File
@@ -768,3 +768,38 @@ fn border_keytips_without_a_command_stay_silent() {
assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border");
}
}
/// #2964: Alt, H, B opens the Borders menu (Excel shows it with each item's letter) instead of
/// leaving nothing on screen; a letter then picks its item and the menu closes, and Esc backs out.
#[test]
fn borders_keytip_opens_the_borders_menu() {
let mut h = harness(selected("A1:B2"));
sequence(&mut h, &[Key::H, Key::B], false);
h.run_steps(2);
assert_eq!(h.state().keytips.prefix(), Some("HB"));
assert!(egui::Popup::is_any_open(&h.ctx), "Alt, H, B opens the Borders menu");
assert!(h.query_by_label("All Borders").is_some(), "the menu's items show");
letter(&mut h, Key::A, false);
h.run_steps(2);
assert!(!egui::Popup::is_any_open(&h.ctx), "picking an item closes the menu");
assert_eq!(h.state().keytips.prefix(), None);
assert!(sides(&h, "B2").iter().all(|s| *s != gridcraft_engine::model::BorderStyle::None), "A applied All Borders");
// Inside Borders, GridCraft's own item, has a letter too (J): only the inner edges.
let mut h = harness(selected("A1:B2"));
sequence(&mut h, &[Key::H, Key::B, Key::J], false);
h.run_steps(2);
let (a1, b2) = (sides(&h, "A1"), sides(&h, "B2"));
use gridcraft_engine::model::BorderStyle::{None as N, Thin};
assert_eq!(a1, [N, Thin, N, Thin], "A1: right and bottom only");
assert_eq!(b2, [Thin, N, Thin, N], "B2: left and top only");
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::B], false);
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx));
key(&mut h, Key::Escape);
h.run_steps(2);
assert_eq!(h.state().keytips.prefix(), Some("H"), "Esc backs out to the Home letters");
assert!(!egui::Popup::is_any_open(&h.ctx), "and closes the menu");
}

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