Compare commits

..
66 Commits
Author SHA1 Message Date
Dylan M. Taylor b8771d51fb Merge pull request #2390 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 16:59:11 -04:00
Dylan M. Taylor 2329507a8e ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:51:16 -04:00
Brandon ThomasandClaude Opus 5.5 7cf44d0fbb Integration and review fixes for #2367, #2369 and #2387
- #2369: its test joins the single ui-egui integration-test binary (tests/it), as main now builds them.
- #2367: a cell set to empty text is a blank entry and doesn't grow the AutoFilter (CodeRabbit).
- #2387: MOD's range check also covers the sign-corrected remainder (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:05:43 -04:00
Brandon Thomas 81fd0f6f7d Merge PR #2387 2026-10-23 16:05:03 -04:00
Brandon Thomas 68da23e2eb Merge PR #2386 2026-10-23 16:05:01 -04:00
Brandon Thomas fba4224ffc Merge PR #2375 2026-10-23 16:04:59 -04:00
Brandon Thomas 2a4cc2a5b6 Merge PR #2369 2026-10-23 16:04:58 -04:00
Brandon Thomas ca0c3afbab Merge PR #2367 2026-10-23 16:04:56 -04:00
Brandon Thomas 87935eb245 Merge PR #2366 2026-10-23 16:04:55 -04:00
NetterSilverandClaude Haiku 5.5 c746eea876 Fix MOD returning wrong sign and magnitude for tiny divisors
Found while testing crates/functions/src/math.rs:305-319

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 21:43:22 +02:00
aiongg 15a705e234 i18n: missing translations are reported, never a failure (#2383)
A string a catalog lacks shows in English. Coverage, key-parity and
per-language count tests and tests of one language's exact wording are
removed; UI wiring tests take their expected text from the catalog.
cargo xtask i18n-report lists the missing strings per language and
always passes; cargo xtask ci runs it first.
2026-10-23 15:39:59 -04:00
Marco Arnulfo 43a5a3c435 PivotTables: Show Details checks the report is up to date before looking at the cell 2026-10-23 21:00:07 +02:00
aiongg 530a8cc810 CI: the gate builds with the release craft-fonts pin (#2381) 2026-10-23 14:47:33 -04:00
Marco Arnulfo 7111812b87 PivotTables: Show Details refuses an out-of-date report, and reports its errors instead of editing the cell 2026-10-23 20:41:47 +02:00
aiongg 08820cb4b2 CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#2338)
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 14:32:15 -04:00
Marco Arnulfo 0c14f59555 PivotTables: Show Details, double-click a value cell to list its source records on a new sheet (#2364) 2026-10-23 20:21:27 +02:00
aiongg aa8399be95 Grid: #### is sized by the width of #, so it fits its cell in every font (#2378)
A number too wide for its column is drawn as ####, with as many # as fit. The count used the
width of 0, and in fonts where # is wider than 0 the hashes ran past the cell edge.
accounting_fill::too_wide_fill_number_shows_hashes_not_a_clipped_value caught it on FreeBSD
and on Linux machines without the CI font set.
2026-10-23 14:14:15 -04:00
xc4pr 49f26bdd79 Fill handle: the Auto Fill Options button on a right-to-left sheet is checked in screen space (#2368) 2026-10-23 19:04:27 +02:00
xc4pr 39450b80d1 Hyperlinks: Ctrl+Alt+click on a link extends the selection again (#2368) 2026-10-23 19:04:27 +02:00
aiongg 33a45130a1 Packaging: pin the signing client and appimagetool, and check their hashes (#2073)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Packaging: pin the AppImage runtime and stop clearly without a pinned tool

* Packaging: shellcheck-clean empty hashes
2026-10-23 13:02:16 -04:00
aiongg a7608ada6d CI: one test binary per crate, nextest, line tables only (#2372)
* CI: dev builds keep line tables only

* CI: one integration-test binary per crate

* CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm

cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps.

* CI: docs name cargo xtask test

* CI: agent guides name the moved fuzz test

* CI: tests added on main join their crate's test binary
2026-10-23 12:59:46 -04:00
Marco Arnulfo 45ec9dfbfe Build the saved UiState in the test with struct update syntax (clippy field_reassign_with_default) 2026-10-23 18:44:23 +02:00
MarekSpodBiedry 8f2a60d176 Keep the in-cell editor the cell's width while the text fits (#2336) 2026-10-23 17:56:16 +02:00
Dylan M. Taylor bdf8b8cc75 Use chrono instead of hand-rolled UTC conversion (#2121)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies and the
is_leap / days_in_month calendar helpers with chrono as a workspace dependency
(default-features = false, std only, so wasm is unaffected). The spreadsheet
serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial
60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday
numbering and the 1900/1904 systems keep their explicit special cases in
serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs
the plain proleptic-Gregorian day-number <-> date conversion and the
leap-year / month-length helpers those build on; a small fallback keeps the old
answer for dates outside chrono's NaiveDate range. The duplicate local is_leap
in the financial functions now uses the core one. Output is byte-identical and
pinned by the existing date, parse, financial and logging tests. AGENTS.md now
says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on out-of-range clocks or years; keep calendar results outside chrono's range

- logging::timestamp: chrono's From<SystemTime> unwraps, so a clock past year
  262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
  and read such a clock as the epoch, like the old saturating code. Years past
  9999 are written with {:04} (not %Y, which prefixes a '+').
- date::is_leap: restore the Gregorian leap-year rule for years outside chrono's
  NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic.
- date::days_in_month: compute the December boundary with checked_add so
  days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths
  through days_from_civil's fallback so out-of-range years keep their real
  lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0.
- civil_from_days: add missing docstring.
- AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies

The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback`
copies for dates outside chrono's ±262143-year range, plus a hand-rolled
Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`.
AGENTS.md carved a "narrow fallback" exception for them, which left calendar
arithmetic outside chrono after all.

Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce
the year with `rem_euclid(400)` (leap years and month lengths repeat every 400
years), which also removes the December `i32::MAX` overflow special-case.

chrono is now the only calendar logic, and every output is unchanged: verified
the cycle result equals the old formula over wide ranges and at the i32/i64
extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 =
fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/
`datetime_from_serial` only ever hand the converters in-range years, so their
serials are byte-for-byte the pre-chrono ones (pinned by a new test).

New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both
sides of the epoch, round-tripping, with values from the old formula; the
existing leap/month-length-beyond-range test still passes unchanged. Removed the
AGENTS.md "narrow fallback" exception.

Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check;
clippy --all-targets -D warnings.

* time: preserve out-of-range years in the log timestamp instead of the epoch

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).

apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
  u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
  10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:48:02 -04:00
MarekSpodBiedry 599762f534 AutoFilter: grow the range when a value is entered in the row below it (#2342) 2026-10-23 17:36:39 +02:00
MarekSpodBiedry ba98039988 Keep the in-cell editor wide enough for the next character at high zoom (#2336) 2026-10-23 17:33:31 +02:00
Dylan M. Taylor 168acc5889 packaging: shell, PowerShell and web page fixes (SonarCloud S7688, S7679, S7682, S8677, S7926) (#2323)
- Shell: [[ ]] for conditional tests, positional parameters copied into
  named locals, explicit return at the end of the env.sh helpers.
- PowerShell: the desktop-shortcut smoke test writes its progress with
  Write-Output instead of Write-Host.
- Web: the viewport no longer sets user-scalable=no, so people can zoom
  the page.
2026-10-23 11:17:38 -04:00
Marco Arnulfo a0ceea1468 Open on the Home tab every launch, as in Excel (fixes #2329) 2026-10-23 17:16:30 +02:00
Marco Arnulfo 9135c7f6f0 Ribbon: fall back to Home when the active contextual tab is gone (fixes #2329) 2026-10-23 17:07:42 +02:00
Brandon ThomasandClaude Opus 5.5 f1e29fa828 Review fixes for #2267 and #2292
- range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit).
- Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:15:31 -04:00
Brandon Thomas fe2f9a7582 Merge PR #2357
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas a09454e250 Merge PR #2315
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas bcf55f0f00 Merge PR #2274
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas 4b8725b29b Merge PR #2314 2026-10-23 10:11:12 -04:00
Brandon Thomas e92d8b4e48 Merge PR #2295 2026-10-23 10:11:12 -04:00
Brandon Thomas 817cf80470 Merge PR #2292 2026-10-23 10:11:12 -04:00
Brandon Thomas 2d0fe1f01e Merge PR #2289 2026-10-23 10:11:12 -04:00
Brandon Thomas d1399ff2ca Merge PR #2279 2026-10-23 10:11:12 -04:00
Brandon Thomas 67f13d804c Merge PR #2275 2026-10-23 10:11:12 -04:00
Brandon Thomas 50e5a3e928 Merge PR #2267 2026-10-23 10:11:11 -04:00
Brandon Thomas a725f2dc67 Merge PR #2266 2026-10-23 10:11:11 -04:00
Brandon Thomas 27a33f1ee0 Merge PR #2260 2026-10-23 10:11:11 -04:00
Brandon Thomas 5d0fc0b06a Merge PR #2245 2026-10-23 10:11:11 -04:00
Brandon Thomas 3c62ae27fd Merge PR #2244 2026-10-23 10:11:11 -04:00
Brandon Thomas 073ae2df39 Merge PR #2233 2026-10-23 10:11:11 -04:00
RedBearAKandClaude Opus 5.5 8eda577545 Page Up into frozen rows scrolls the rows below back to the top
With row 1 frozen, Page Up could land on A1, which is always on screen, while the scrolled rows
stayed a page down (row 10, Page Down, Page Up, Page Up ends on row 1 with rows 2-9 hidden). The
same held for Ctrl+Up into frozen rows and Ctrl+Left / Alt+Page Up into frozen columns.

Arrow and paging keys now record the cell they moved from (`GridState::reveal_from`). When such a
move goes from the scrolled rows (columns) into the frozen ones, the scrolled area goes back to
its top (left). Other reveals (clicks, Find, Go To, Name Box, Undo, the control channel, formula
pointing) leave it unset and keep the view where the user scrolled it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:20:18 -08:00
xc4pr 7c1ec1df7d Fill handle: test the Auto Fill Options button on a right-to-left sheet, merge the duplicate fill handle row (#1403) 2026-10-23 02:37:49 +02:00
xc4pr 60776ff4de Hyperlinks: test that Ctrl+click beside the link text still extends the selection (#1198) 2026-10-23 02:23:37 +02:00
xc4pr d19f656014 Fill handle: test the Auto Fill Options button's workbook-changed rule, report an undo error, document it (#1403) 2026-10-23 02:17:38 +02:00
xc4pr 6febf5442f Fill handle: Auto Fill Options button after a drag offers Copy Cells, Fill Series and the formatting modes (#1403)
A left-drag of the fill handle filled at once and offered no choice. A button now appears below the filled range; its menu undoes that fill and reruns edit.autoFill in the chosen mode. It goes away when the workbook changes or the selection moves.
2026-10-23 01:25:21 +02:00
xc4pr d046ba6aa7 Hyperlinks: Ctrl+click on a link follows it instead of extending the selection (#1198)
A Ctrl+click went to the add-to-selection branch, so the link under the pointer was never followed. It now goes through follow_link like a plain click (asks before opening web addresses, jumps to references).
2026-10-23 00:44:34 +02:00
D. Fender 09f78cb3a8 Tables: Format as Table inside a table restyles it
Home > Format as Table on a cell or range inside an existing table
failed with "A table can't overlap another table." Excel applies the
picked style to that table instead. The command now returns that table
and its range, as it does for a new table. A range crossing a table's
edge is still rejected.

Without a range, the active area of the selection decides, as it does
for Insert > Table. The native Format menu's Format as Table now runs
home.formatAsTable instead of insert.table.
2026-10-22 11:43:58 -07:00
coolvitto a8e2ed31b1 Add extra Japanese translations for UI elements
Added extra labels for ribbon menus and pop-ups in Japanese.
2026-10-23 03:05:42 +09:00
NetterSilverandClaude Haiku 5.5 9cbb674c55 Print numbers near f64::MAX in a form that parses back
Found while testing crates/formula/src/printer.rs:134

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:34:06 +02:00
Dominik StrebelandClaude Opus 5.5 74f29a743f docs: Ctrl+Shift+A (argument names) is no longer listed as missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:18:45 +02:00
Dominik StrebelandClaude Opus 5.5 e6ffca60bb view: hidden outline symbols answer no clicks; parity rows updated
With headings hidden too, a level button sat across the empty column band
at the grid's top edge, so a click there still ran it. Outline hit testing
now stops when both bands are empty. The parity doc no longer lists Ctrl+8
as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:09:29 +02:00
Dominik StrebelandClaude Opus 5.5 5bcbaacce7 Snapshot: catalog installed fonts before rendering
The offscreen snapshot never started the background font scan the app runs
from its first frame, so a workbook's installed fonts (Helvetica Neue, say)
were drawn in the Calibri substitute. Catalog them up front and run a few more
frames so they bind, as in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:02:58 +02:00
Dominik StrebelandClaude Opus 5.5 840ba5c3a5 view: Show Outline Symbols (Ctrl+8), read and written
A sheet can hide its outline's level buttons and brackets while keeping
the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't
modelled, so such sheets showed a wide outline band Excel doesn't.

- model: Sheet::show_outline_symbols (default on, also for older JSON).
- xlsx: read and written on the sheet view.
- engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable.
- ui: no outline bands while it's off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:54:06 +02:00
Hamza 99f92f4a28 range.setValues: parse: false stores a string as the text it is (#2255)
Every string in `values` went through typed input, which reads it with en-US
rules, and nothing said so:

    "05.04.2026"  (dd.mm.yyyy)  → the date 4 May 2026
    "02/11/2026"  (dd/mm/yyyy)  → the date 11 February 2026
    "2.500"       (thousands)   → the number 2.5
    "4.25 %"      (text)        → 0.0425, formatted 0.00%

A workbook built from Turkish-format exports therefore held different values
than the script put in it, and the only way to notice was to read the saved
file back cell by cell.

`parse: false` stores every string as the text it is. A formula is still a
formula — `=` means a formula in a cell, and the ticket asks for that — and
numbers, booleans and null are unaffected either way. A leading apostrophe is
part of the text rather than the marker that makes it text, since nothing is
being marked.

The default is unchanged: `parse` is absent in every existing call, and those
calls behave exactly as before. A `parse` that is neither true nor false is a
usage error rather than a quiet default.

The test pins both sides — the swapped date, the decimal point and the
percentage under today's default; each of the six strings stored verbatim with
`parse: false`, with `=1+1` still calculating 2; numbers and booleans either
way; and the refused `parse` values. Without the change the first string comes
back as a date.
2026-10-22 18:05:53 +05:00
Dominik StrebelandClaude Opus 5.5 843c9b8404 ui: a too-wide number in a repeat-fill format shows ####
A number too wide for its column is drawn as ####. With a repeat fill
(`*` in the format, as in the Accounting format) the fill step then
rebuilt the text from the formatted value, so it overflowed and, being
right-aligned, lost its leading digits: 2,509.00 CHF in a narrow column
was drawn as "509.00 CHF". The fill now leaves #### alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:00:06 +02:00
Hamza 56207f2d3c run --script: a line's unknown key stops the run (#2256)
A script line whose parameters sat under a key the runner does not read ran the
command with no parameters at all, and the run still exited 0:

    {"command":"home.insertSheet","args":{"name":"Report"}}
    {"command":"home.insertSheet","name":"Report2"}

made Sheet2 and Sheet3 instead of Report and Report2, and said nothing. A typo
in a script produced a valid, wrong workbook.

A line is read for `command` (or `id`) and `params`; any other key is a usage
error now, naming the keys and the line, and the run stops before the workbook
is written:

    gridcraft-cli: s.jsonl:2: unknown key `args`: a line is {"command": "id", "params": {…}}

The usage text says what a line looks like, which is the other half of the
report — the format was not written down anywhere, so the reporter found it by
trying `args`, a flat object and `id` in turn.

Tested at both levels: `parse_command` for the keys, their sorted list when
there are several, and the forms that are read (`id`, `params`, `id={json}`,
comments); and a run of a four-line script that stops at line 2 without writing
the file, then the same script corrected, whose sheets come out named. Without
the check the mistaken line parses again.

An unknown key *inside* `params` is still ignored — the rest of #2256 — and
needs the command's parameter list, which the engine does not expose yet. Noted
on the issue.
2026-10-22 17:50:09 +05:00
NetterSilverandClaude Opus 5.5 8307d3cb05 Treat empty instance_num in TEXTBEFORE/TEXTAFTER as 1
Found while testing crates/functions/src/text.rs:553

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:25:54 +00:00
NetterSilverandClaude Opus 5.5 c39d2ad197 Keep '!' in structured-reference columns intact on XLSX save
Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:47:21 +02:00
Tyrel HavemanandClaude Opus 5.5 742a6e8535 functions: REGEXREPLACE bounds each replacement as it expands it (#2162)
From the local CodeRabbit review: the result was bounded per match, but
one match's expansion was built whole first, so a replacement repeating
$0 10,000 times over a 32,767-character match allocated about 330 MB
before the check. The replacement is now split into pieces as regex's
Captures::expand reads it (template_expands_like_regex checks they agree)
and appended piece by piece within the bound: 327 MB peak became 7 MB
(regexreplace_bounds_its_result).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-21 23:45:50 -07:00
Tyrel HavemanandClaude Opus 5.5 e177bf1c44 calc: hardening: overlong digits, NaN indexes, REGEXREPLACE output (#2163, #2162)
- Text coercion: digits too many for a finite number are not a number,
  and a fraction must come out finite, so "1 <400 digits>/<400 digits>"
  (inf/inf) no longer coerces to NaN (test overlong_digits_stay_text).
- VLOOKUP/HLOOKUP and the D-functions' field index refuse NaN, which
  passed their "< 1" checks and became index 0, then underflowed
  (test nan_index_is_a_value_error; hostile_inputs_never_panic now also
  passes NaN and infinity to every function).
- REGEXREPLACE replaces match by match and gives up once the result can
  only be too long, instead of building every replacement first: the
  32,767 x 32,767 worst case went from 1.05 GB peak to 7 MB
  (test regexreplace_bounds_its_result).

#2165 (MAP's size check) is already fixed on main by the broadcast cap
(836b2698, #2182), whose test covers MAP.

Also adds my contributors/people.toml entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-21 23:41:28 -07:00
Viorel ToaderandClaude Opus 5.5 b9801f3f81 Romanian: translate Options|Editing; 22 strings main added since (sheet tab menu, account, chart and error-checking labels)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-22 07:55:04 +03:00
192 changed files with 3007 additions and 762 deletions
+13 -33
View File
@@ -1,8 +1,7 @@
# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land
# a red tree that only the next agent session discovers.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -12,37 +11,18 @@ on:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Linux build dependencies (winit, wgpu, rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
run: cargo xtask ci
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+5
View File
@@ -2,6 +2,9 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [release]
@@ -10,6 +13,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "57 2 * * *"
workflow_dispatch:
concurrency:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+2 -1
View File
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **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).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
+1 -1
View File
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **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).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
Generated
+11
View File
@@ -619,6 +619,15 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "cipher"
version = "0.5.2"
@@ -1499,6 +1508,7 @@ name = "gridcraft"
version = "0.11.1"
dependencies = [
"arboard",
"chrono",
"craft-print",
"eframe",
"egui",
@@ -1555,6 +1565,7 @@ dependencies = [
name = "gridcraft-core"
version = "0.11.1"
dependencies = [
"chrono",
"js-sys",
"serde",
"serde_json",
+4
View File
@@ -30,6 +30,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.41"
@@ -66,9 +68,11 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
+33
View File
@@ -43,6 +43,8 @@ const USAGE: &str = "usage:
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
[--out FILE] [--print RANGE] [--quiet]
a script line is id={json} or {\"command\": \"id\", \"params\": {…}};
blank lines and lines starting with # or // are skipped
gridcraft-cli commands [--json] [--search X]
gridcraft-cli functions [--json] [--search X] [--category C]
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
@@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result<Option<(String, Value)>, String> {
}
if t.starts_with('{') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?;
// A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and
// `{"command": …, "name": "Report"}` ran the command with no parameters at all and the
// script still exited 0, so a typo made a valid, wrong workbook (#2256).
if let Some(o) = v.as_object() {
let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect();
if !unknown.is_empty() {
unknown.sort_unstable();
let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `"));
return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}"));
}
}
let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?;
return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({})))));
}
@@ -567,6 +580,26 @@ mod tests {
assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({}))));
assert_eq!(parse_command(" # comment").unwrap(), None);
assert!(parse_command("x={bad").is_err());
// A key this does not read is a mistake: the command used to run with no parameters and
// the script still exited 0, so a typo made a valid, wrong workbook (#2256).
for (line, named) in [
(r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"),
(r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"),
(r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"),
] {
let e = parse_command(line).expect_err("refused");
assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}");
assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}");
}
// Several at once are listed in one message, sorted.
let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused");
assert!(e.contains("unknown keys `args`, `name`"), "{e}");
// `id` is still the accepted alternative to `command`, and `params` still carries them.
assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({}))));
assert_eq!(
parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(),
Some(("home.insertSheet".into(), json!({"name": "R"})))
);
}
#[test]
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
@@ -206,3 +204,41 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
let _ = std::fs::remove_dir_all(dir);
}
/// A script line whose parameters are under a key `run` does not read used to run the command
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
#[test]
fn a_script_line_with_an_unknown_key_stops_the_run() {
let dir = tmpdir("script-keys");
let script = dir.join("s.jsonl");
let out = dir.join("t.xlsx");
let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap();
write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]);
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
let err = String::from_utf8_lossy(&o.stderr);
assert!(!o.status.success(), "the run must stop: {err}");
assert!(err.contains("s.jsonl:2"), "it names the line: {err}");
assert!(err.contains("unknown key `args`"), "and the key: {err}");
assert!(!out.exists(), "and writes no workbook: {err}");
// The forms that are read still work, in one script: `params`, `id=json`, and a comment.
write(&[
r#"{"command":"file.new"}"#,
r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#,
"# a comment",
r#"home.insertSheet={"name":"Report2"}"#,
]);
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = ok(&["info", out.to_str().unwrap()]);
assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}");
assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}");
// The usage text says what a line looks like, which is how this was found.
let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout);
assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}");
let _ = std::fs::remove_dir_all(&dir);
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, one module per file, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod text_to_columns_quotes;
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GridCraft</title>
<link data-trunk rel="icon" href="../../assets/app-icon/gridcraft.ico" />
<link data-trunk rel="rust" data-bin="gridcraft-web" data-wasm-opt="z" data-weak-refs />
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
{
// No usable saved language: keep the browser's, as the desktop keeps the system's.
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
app.ui = state;
app.ui = state.at_startup();
app.ui.language = language;
}
if let Some(s) = prefs.filter(readable)
+1
View File
@@ -18,6 +18,7 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
+36 -19
View File
@@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -393,6 +399,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+1 -1
View File
@@ -111,7 +111,7 @@ fn load_prefs(app: &mut SheetApp) {
{
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
let language = saved.unwrap_or(app.ui.language);
app.ui = ui;
app.ui = ui.at_startup();
app.ui.language = language;
}
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
+1 -1
View File
@@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Unhide Sheet…", "sheet.unhide", None),
("-", "", None),
("Conditional Formatting…", "dialog:manageRules", None),
("Format as Table", "insert.table", None),
("Format as Table", "home.formatAsTable", None),
],
),
(
+3
View File
@@ -93,3 +93,6 @@ sync_github_name = false
[people.bilalmughal]
sync_github_name = true
[people.tyrel]
real_name = "Tyrel Haveman"
+13
View File
@@ -1768,3 +1768,16 @@ fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
t.set("E11", "=ROWS(D1#)");
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
}
#[test]
fn text_before_after_empty_instance_defaults_to_one() {
let mut t = T::new();
t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)");
assert_eq!(t.get("A1"), Value::Text("Little ".into()));
t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")");
assert_eq!(t.get("A2"), Value::Text("b".into()));
t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")");
assert_eq!(t.get("A3"), Value::Text("nf".into()));
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
+111 -24
View File
@@ -28,15 +28,22 @@ pub struct DateTime {
pub weekday: u32,
}
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
///
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = m as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
.and_then(|date| {
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
}
}
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
/// [`days_from_civil`]).
///
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
/// 1970-01-01 is `num_days_from_ce` 719_163.
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
use chrono::Datelike;
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
}
// Unreachable: `rest + 719_163` is always a valid CE day.
None => (1970, 1, 1),
}
}
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
const UNIX_1904_01_01: i64 = -24107;
/// Whether `y` is a Gregorian leap year, for every `i32` year.
pub fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
let y = i64::from(y).rem_euclid(400) as i32;
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
}
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
/// `i32` year.
pub fn days_in_month(y: i32, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
// The length of a month is the day before the first of the next month. Month lengths repeat
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
if !(1..=12).contains(&m) {
return 0;
}
let y = i64::from(y).rem_euclid(400) as i32;
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
_ => 0,
}
}
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
assert!(is_leap(400_000)); // divisible by 400
assert!(!is_leap(300_100)); // century not divisible by 400
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
assert_eq!(days_in_month(400_000, 2), 29);
assert_eq!(days_in_month(300_100, 2), 28);
assert_eq!(days_in_month(400_000, 1), 31);
assert_eq!(days_in_month(400_000, 4), 30);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2023, 0), 0);
assert_eq!(days_in_month(2023, 13), 0);
// December at i32::MAX does not overflow and still reports 31 days.
assert_eq!(days_in_month(i32::MAX, 12), 31);
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
}
#[test]
fn civil_conversions_exact_beyond_chrono_range() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
// the epoch; values round-trip through the proleptic Gregorian converters.
for (y, m, d, z) in [
(262_144i32, 1u32, 1u32, 95_026_602i64),
(300_000, 1, 1, 108_853_222),
(400_000, 2, 29, 145_377_531),
(-262_145, 3, 1, -96_465_964),
(-300_000, 6, 15, -110_292_112),
] {
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
}
// The extremes must not overflow or panic (i128 intermediates).
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
}
#[test]
fn serials_unchanged_through_cycle_conversion() {
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(days_in_month_in(s, 1900, 2), 29);
let s4 = DateSystem::D1904;
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
}
#[test]
fn serial_from_unix_time() {
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
+17 -2
View File
@@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
}
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
/// finite number (#2163).
fn plain_number(s: &str, frac: bool) -> Option<f64> {
let (int, dec) = match s.split_once('.') {
Some((i, d)) if frac => (i, Some(d)),
@@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option<f64> {
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
return None;
}
s.parse().ok()
s.parse().ok().filter(|x: &f64| x.is_finite())
}
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
@@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option<f64> {
return None;
}
let v = w + a / b;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
@@ -366,6 +370,17 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
/// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
#[test]
fn overlong_digits_stay_text() {
let big = "9".repeat(400);
for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
+45
View File
@@ -652,6 +652,24 @@ fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
return;
}
let Some(af) = sh.autofilter.as_ref() else { return };
let r = af.range;
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
return;
}
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
if let Some(af) = sh.autofilter.as_mut() {
af.range = grown;
}
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
@@ -1554,6 +1572,33 @@ mod tests {
}
}
#[test]
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
assert_eq!(range(&s), Some("A1:B4".to_string()));
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A row typed after a blank row does not grow it.
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A cell outside the filter's columns does not either.
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// Empty text is a blank entry, not a value.
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A formula and Ctrl+Enter (range.fill) are entries too.
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
assert_eq!(range(&s), Some("A1:B6".to_string()));
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
assert_eq!(range(&s), Some("A1:B7".to_string()));
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
+75 -2
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
"{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)}",
has_doc,
range_set_values
),
@@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at);
}
}
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
Ok(Json::Null)
})?;
@@ -299,6 +300,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
@@ -321,6 +331,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// 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);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
@@ -330,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at);
}
}
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
count += 1;
}
@@ -370,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
cx.sheet_mut(sheet)?.set_cell(at, cell);
let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
}
}
@@ -1769,6 +1793,55 @@ mod tests {
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+17 -2
View File
@@ -26,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Format as Table",
["Home", "Styles"],
None,
"{range?, style: \"TableStyleMedium2\", header?}",
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
has_doc,
insert_table
format_as_table
),
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
s, p, "totals"
@@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
+227 -10
View File
@@ -179,6 +179,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
change_source
),
cmd!(
"pivot.showDetails",
"Show Details",
[],
None,
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}",
has_doc,
show_details
),
cmd!(
"pivot.delete",
"Delete PivotTable",
@@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let (si, anchor) = match target {
Some(t) => t,
None => {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
let at = insert_sheet_before_active(cx);
new_sheet_at = Some(at);
(at, CellRef::new(2, 0))
}
@@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
Ok(res)
}
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
at
}
/// Show Details (double-click a value cell): the source records behind it, as values with their
/// column's number format, under the source headers on a new sheet.
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
.iter()
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?;
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
if d.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let count = records.len();
let mut new_sheet_at: Option<usize> = None;
let res = edit(s, |cx| {
let si = insert_sheet_before_active(cx);
new_sheet_at = Some(si);
let base = cx.wb.styles.get(StyleId::default()).clone();
let fmts: Vec<StyleId> = src
.formats
.iter()
.map(|f| {
let mut st = base.clone();
st.num_fmt = NumFmt::new(f);
cx.wb.styles.intern(st)
})
.collect();
// Columns wide enough for their text, as the PivotTable report's are (dates would show
// as ####); the first rows are enough to judge a column.
let font = base.font.clone();
let widths: Vec<f32> = src
.headers
.iter()
.enumerate()
.map(|(c, h)| {
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
let widest = texts
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
(widest + 14.0).min(320.0)
})
.collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths.into_iter().enumerate() {
if w > sh.col_width(c as u32) {
sh.cols.entry(c as u32).or_default().size = Some(w);
}
}
for (c, h) in src.headers.iter().enumerate() {
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
for (c, v) in rec.iter().enumerate() {
let style = fmts.get(c).copied().unwrap_or_default();
if v.is_empty() && style == StyleId::default() {
continue;
}
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
}
}
let name = sh.name.clone();
cx.wb.active_sheet = si;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
})?;
if let Some(at) = new_sheet_at
&& let Ok(d) = s.doc_mut()
{
d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(res)
}
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "table") {
@@ -2236,6 +2334,7 @@ mod tests {
"data.refreshAll",
"pivot.changeSource",
"pivot.delete",
"pivot.showDetails",
];
let hostile = [
json!({}),
@@ -2363,4 +2462,122 @@ mod tests {
assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
/// The active sheet's first five columns as shown, down to the first blank row.
fn sheet_rows(s: &mut Session) -> Vec<Vec<String>> {
let mut out = Vec::new();
for r in 0..30u32 {
let row: Vec<String> = (0..5)
.map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string())
.collect();
if row.iter().all(String::is_empty) {
break;
}
out.push(row);
}
out
}
#[test]
fn show_details_lists_a_value_cells_records_on_a_new_sheet() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 is East × Apples (100 + 60); the active cell is the default.
s.execute("selection.set", json!({"range": "B5"})).unwrap();
let r = s.execute("pivot.showDetails", json!({})).unwrap();
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
vec![
vec!["Region", "Product", "Date", "Units", "Sales"],
vec!["East", "Apples", "1/15/2026", "10", "100"],
vec!["East", "Apples", "9/9/2026", "6", "60"],
]
);
// The dates fit their column instead of showing ####.
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5));
// One undo step takes the sheet away.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(val(&s, "B5"), Value::Number(160.0));
}
#[test]
fn show_details_on_a_total_takes_every_record_it_adds_up() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
let records = |s: &mut Session, cell: &str| {
let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap();
s.execute("edit.undo", json!({})).unwrap();
n
};
assert_eq!(records(&mut s, "E5"), 5, "East's row total");
assert_eq!(records(&mut s, "C8"), 4, "Pears' column total");
assert_eq!(records(&mut s, "E8"), 12, "the grand total");
// A subtotal at the top of its group, in a two-level row axis.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(records(&mut s, "B4"), 5);
assert_eq!(text(&s, "A6"), "Pears");
assert_eq!(records(&mut s, "B6"), 2);
}
#[test]
fn show_details_leaves_out_the_records_the_report_filters_out() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap();
assert_eq!(r["records"], 2);
let rows = sheet_rows(&mut s);
assert_eq!(rows.len(), 3);
assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}");
}
#[test]
fn show_details_refuses_a_report_its_source_has_moved_on_from() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 still shows East × Apples (160), but the first East record is now Central's.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
assert_eq!(val(&s, "B5"), Value::Number(160.0));
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
// After a refresh East × Apples (now B6) has the one record left.
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(text(&s, "A6"), "East");
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1);
}
#[test]
fn show_details_refuses_a_report_that_would_now_be_shorter() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total.
s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap();
assert_eq!(text(&s, "A8"), "Grand Total");
let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn show_details_needs_a_value_cell() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] {
assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
}
+2
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
@@ -166,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"gridlines" => &mut sh.show_gridlines,
"headings" => &mut sh.show_headings,
"zeros" => &mut sh.show_zeros,
"outlineSymbols" => &mut sh.show_outline_symbols,
"rtl" => &mut sh.right_to_left,
"printGridlines" => &mut sh.print.gridlines,
_ => &mut sh.print.headings,
+53
View File
@@ -803,6 +803,51 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
compute_from(wb, pt, &src, None)
}
/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end
/// edits that cell instead).
pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
/// Show Details: the source list and the indices of its records behind the value cell at
/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when
/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
// Excel reads its PivotTable cache; without one, the report written by the last refresh
// must still be what the source gives. Checked first: in a stale report even a cell that
// computes as a label may show a value.
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
let current = pt.last_range.is_some_and(|r| {
r.height() == out.height().max(1)
&& r.width() == out.width.max(1)
&& out.grid.iter().enumerate().all(|(ri, line)| {
line.iter().enumerate().all(|(ci, oc)| {
sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value
})
})
});
if !current {
return Err("The PivotTable report is out of date. Refresh it first.".into());
}
let Some((rp, cp)) = probe.hit else { return Ok(None) };
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
Ok(Some((src, records)))
}
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
/// value cell at `at`, and the paths of every record the filters let through.
struct Probe {
at: (usize, usize),
hit: Option<(Vec<u32>, Vec<u32>)>,
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
}
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
@@ -914,6 +959,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
if let Some(pr) = probe.as_deref_mut() {
pr.paths.push((ri, rp.clone(), cp.clone()));
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
@@ -1278,6 +1326,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
_ => raw,
},
};
if let Some(pr) = probe.as_deref_mut()
&& pr.at == (r, l + j)
{
pr.hit = Some((rp.to_vec(), cp.to_vec()));
}
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
}
}
+15
View File
@@ -2720,6 +2720,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() {
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
}
/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting.
#[test]
fn show_outline_symbols_toggles() {
let mut s = s();
let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols;
assert!(on(&s));
assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false}));
assert!(!on(&s));
s.execute("edit.undo", json!({})).unwrap();
assert!(on(&s));
s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap();
reopen(&mut s);
assert!(!on(&s));
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
@@ -23,7 +23,7 @@
//!
//! Knobs (environment variables):
//! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`.
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`.
//! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line).
//! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that
//! still fails the same way, printed as `execute` calls ready for a regression test.
@@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure)
None => format!("end-of-run checks after {} steps", sequence.len()),
};
let mut out = format!(
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n",
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n",
f.kind, f.what
);
let end = (f.step + 1).min(sequence.len());
@@ -0,0 +1,61 @@
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap();
s
}
fn styles(s: &Session) -> Vec<String> {
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect()
}
#[test]
fn format_as_table_inside_a_table_restyles_it() {
let mut s = table();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_on_a_range_inside_a_table_restyles_it() {
let mut s = table();
s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap();
assert_eq!(styles(&s), ["TableStyleDark3"]);
}
#[test]
fn format_as_table_across_a_table_edge_is_rejected() {
let mut s = table();
let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err();
assert!(e.to_string().contains("overlap"), "{e}");
assert_eq!(styles(&s), ["TableStyleMedium2"]);
}
#[test]
fn format_as_table_inside_a_table_returns_that_table() {
let mut s = table();
let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap();
assert_eq!(r, json!({"table":"T", "range":"A1:B3"}));
}
#[test]
fn format_as_table_uses_the_active_area_of_a_multi_area_selection() {
let mut s = table();
s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() {
let mut s = table();
s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
+29
View File
@@ -0,0 +1,29 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod autofit_filter_buttons;
mod cf_formula_validate;
mod chart_workbook_names;
mod command_sequence_fuzz;
mod cross_sheet_cut;
mod csv_export_budget;
mod custom_filter_blanks;
mod custom_view_freeze;
mod dynamicmetadata;
mod extreme_selection_moves;
mod format_as_table_restyle;
mod hidden_sheet_navigation;
mod ink_roundtrip;
mod line_count_overflow;
mod long_operator_chains;
mod offset_overflow;
mod percent_entry;
mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod row_autofit;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod table_resize_overlap;
mod table_total_row_last_row;
mod wrap_row_height;
+33 -2
View File
@@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String {
write_struct_ref(s, false)
}
/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets
/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`).
fn sheet_split(n: &str) -> Option<(&str, &str)> {
let mut depth = 0usize;
let mut escaped = false;
let mut at = None;
for (i, c) in n.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\'' if depth > 0 => escaped = true,
'[' => depth = depth.saturating_add(1),
']' => depth = depth.saturating_sub(1),
'!' if depth == 0 => at = Some(i),
_ => {}
}
}
at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?)))
}
fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#");
let item = |i: &StructItem| match i {
@@ -132,7 +154,16 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
}
fn number(n: f64) -> String {
number_to_text(n)
let text = number_to_text(n);
// 15 significant digits can round past f64::MAX, which no longer parses.
if text.parse::<f64>().is_ok_and(f64::is_finite) {
return text;
}
let exact = format!("{:E}", n);
match exact.split_once('E') {
Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"),
_ => exact,
}
}
/// Prints an expression in A1 notation.
@@ -178,7 +209,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
Some(at) => s.push_str(&reference_r1c1(r, at)),
None => s.push_str(&reference_a1(r)),
},
Expr::Name(n) => match n.rsplit_once('!') {
Expr::Name(n) => match sheet_split(n) {
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
Some((sheet, name)) => {
s.push_str(&quote_sheet(sheet));
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod printer_roundtrip;
mod spanish_input;
@@ -0,0 +1,15 @@
use gridcraft_formula::{Expr, parse, print};
#[test]
fn number_near_f64_max_round_trips() {
for src in ["=1.7976931348623157E308", "=-1.7976931348623157E308"] {
let e = parse(src).unwrap();
let printed = print(&e);
let back = parse(&printed).unwrap_or_else(|err| panic!("{printed}: {err:?}"));
assert_eq!(print(&back), printed);
match (&e, &back) {
(Expr::Number(a), Expr::Number(b)) => assert_eq!(a, b),
_ => assert_eq!(e, back),
}
}
}
+3 -2
View File
@@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
match v {
Value::Number(n) => {
let i = n.trunc();
if i < 1.0 || i > db.cols as f64 {
// NaN too: it would pass both comparisons and become index 0 (#2163).
if i.is_nan() || i < 1.0 || i > db.cols as f64 {
return Err(CellError::Value);
}
Ok(Some(i as usize - 1))
Ok(Some((i as usize).saturating_sub(1)))
}
Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value),
Value::Bool(_) => Err(CellError::Value),
+34
View File
@@ -264,6 +264,37 @@ mod tests {
}
}
/// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`).
#[test]
fn nan_index_is_a_value_error() {
use crate::util::testutil::*;
use gridcraft_core::CellError;
let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]]));
let nan = || av(Value::Number(f64::NAN));
assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value));
assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value));
assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value));
assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0));
}
/// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces
/// as before (every match, `$n` groups).
#[test]
fn regexreplace_bounds_its_result() {
use crate::util::testutil::*;
use gridcraft_core::CellError;
let many = "a".repeat(32_767);
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value));
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767)));
assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c"));
assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026"));
assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-"));
assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#"));
// One match whose replacement repeats it 10,000 times: refused before it is built.
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value));
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value));
}
#[test]
fn hostile_inputs_never_panic() {
use crate::util::testutil::*;
@@ -279,6 +310,9 @@ mod tests {
n(-1e308),
n(1e10),
n(2.5),
// Numbers no formula should make, but code can (#2163).
av(Value::Number(f64::NAN)),
av(Value::Number(f64::INFINITY)),
t(""),
t("abc"),
t("*?~"),
+3 -2
View File
@@ -134,10 +134,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R<Value> {
return Err(*e);
}
let idx = num(args, 2)?;
if idx < 1.0 {
// NaN too: it would pass `idx < 1.0` and become index 0 (#2163).
if idx.is_nan() || idx < 1.0 {
return Err(CellError::Value);
}
let idx = idx.trunc() as usize - 1;
let idx = (idx.trunc() as usize).saturating_sub(1);
let approx = opt_bool(args, 3, true)?;
let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) };
if idx >= len {
+16 -5
View File
@@ -306,12 +306,10 @@ fn modulo(x: f64, d: f64) -> R<f64> {
if d == 0.0 {
return Err(CellError::Div0);
}
let q = (x / d).floor();
let r = x - d * q;
// `%` 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.
if r != 0.0 && (r < 0.0) != (d < 0.0) {
return Ok(r + d);
}
let r = if r != 0.0 && (r < 0.0) != (d < 0.0) { r + d } else { r };
if r.abs() >= d.abs() {
return Ok(0.0);
}
@@ -1442,6 +1440,19 @@ mod tests {
close(ev1("ODD", 0.0), 1.0);
}
#[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:?}"),
}
}
}
#[test]
fn arithmetic() {
close(ev2("MOD", 3.0, 2.0), 1.0);
+99 -9
View File
@@ -5,8 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text};
use crate::criteria::wildcard_find;
use crate::util::{
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text,
text_val,
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, int, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar,
text, text_val,
};
use crate::{Arg, Ctx, FnSpec, VAR};
@@ -550,7 +550,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
let s = chars(&text(a, 0)?);
let delims = delimiters(&arg(a, 1)?.value)?;
let inst = opt_int(a, 2, 1)?;
let inst = if has(a, 2) { int(a, 2)? } else { 1 };
let mode = opt_int(a, 3, 0)?;
let match_end = opt_bool(a, 4, false)?;
if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) {
@@ -763,23 +763,99 @@ fn convert_replacement(r: &str) -> String {
out
}
/// A piece of a replacement: literal text or a capture group (by number or name).
enum Piece {
Text(String),
Index(usize),
Name(String),
}
/// A replacement split into pieces as `regex::Captures::expand` reads it: `$$` is `$`, `$name` takes
/// the longest run of `[_0-9A-Za-z]`, `${name}` ends at `}` (`${}` is empty), a name of digits is a
/// group number, and a `$` that starts no reference is itself.
fn template(rep: &str) -> Vec<Piece> {
let mut out = Vec::new();
let mut lit = String::new();
let mut rest = rep;
while let Some(i) = rest.find('$') {
lit.push_str(&rest[..i]);
rest = &rest[i + 1..];
if let Some(r) = rest.strip_prefix('$') {
lit.push('$');
rest = r;
continue;
}
// A braced name is a reference even when empty (it expands to nothing).
let braced = rest.strip_prefix('{').and_then(|b| b.find('}').map(|j| (&b[..j], &b[j + 1..])));
let (name, after) = braced.unwrap_or_else(|| {
let j = rest.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())).unwrap_or(rest.len());
(&rest[..j], &rest[j..])
});
if braced.is_none() && name.is_empty() {
lit.push('$');
continue;
}
if !lit.is_empty() {
out.push(Piece::Text(std::mem::take(&mut lit)));
}
out.push(match name.parse::<usize>() {
Ok(n) => Piece::Index(n),
Err(_) => Piece::Name(name.to_string()),
});
rest = after;
}
lit.push_str(rest);
if !lit.is_empty() {
out.push(Piece::Text(lit));
}
out
}
/// Append a match's replacement to `out`, piece by piece; `None` once `out` would pass `limit`
/// bytes, before anything that long is built (#2162).
fn expand_capped(cap: &regex::Captures, pieces: &[Piece], out: &mut String, limit: usize) -> Option<()> {
for p in pieces {
let s = match p {
Piece::Text(t) => t.as_str(),
Piece::Index(i) => cap.get(*i).map_or("", |m| m.as_str()),
Piece::Name(n) => cap.name(n).map_or("", |m| m.as_str()),
};
if out.len().saturating_add(s.len()) > limit {
return None;
}
out.push_str(s);
}
Some(())
}
fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = text(a, 0)?;
let rep = convert_replacement(&text(a, 2)?);
let rep = template(&convert_replacement(&text(a, 2)?));
let occ = opt_int(a, 3, 0)?;
let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?;
// Built match by match within a bound past which the text can only be too long (a character
// is at most four bytes), instead of building every replacement first (#2162).
let limit = MAX_TEXT * 4;
let mut out = String::new();
if occ == 0 {
return check_len(re.replace_all(&s, rep.as_str()).into_owned());
let mut last = 0;
for cap in re.captures_iter(&s) {
let Some(m) = cap.get(0) else { continue };
out.push_str(s.get(last..m.start()).unwrap_or_default());
expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?;
last = m.end();
}
out.push_str(s.get(last..).unwrap_or_default());
return check_len(out);
}
let caps: Vec<regex::Captures> = re.captures_iter(&s).collect();
let k = occ.unsigned_abs() as usize;
let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) };
let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) };
let Some(m) = cap.get(0) else { return text_val(s) };
let mut out = String::new();
out.push_str(&s[..m.start()]);
cap.expand(&rep, &mut out);
out.push_str(&s[m.end()..]);
out.push_str(s.get(..m.start()).unwrap_or_default());
expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?;
out.push_str(s.get(m.end()..).unwrap_or_default());
check_len(out)
}
@@ -957,6 +1033,20 @@ pub(crate) fn specs() -> Vec<FnSpec> {
mod tests {
use gridcraft_core::{CellError, Value};
/// The pieces expand exactly as `regex::Captures::expand` does.
#[test]
fn template_expands_like_regex() {
let re = regex::Regex::new(r"(?<y>\d+)-(\d+)").unwrap();
let cap = re.captures("x 2026-10 y").unwrap();
for rep in ["$1", "${1}", "$1a", "${1}a", "$y", "${y}!", "$$", "$$1", "a$", "$}", "${", "${}", "${y", "$0|$2|$9", "$-1", "€$1€", ""] {
let mut want = String::new();
cap.expand(rep, &mut want);
let mut got = String::new();
assert!(super::expand_capped(&cap, &super::template(rep), &mut got, usize::MAX).is_some());
assert_eq!(got, want, "{rep:?}");
}
}
use crate::util::testutil::*;
#[test]
+9
View File
@@ -76,6 +76,10 @@ pub struct Sheet {
pub show_headings: bool,
pub show_formulas: bool,
pub show_zeros: bool,
/// The outline's level buttons and brackets (Excel's Show Outline Symbols, Ctrl+8); the
/// outline itself stays.
#[serde(default = "yes")]
pub show_outline_symbols: bool,
pub right_to_left: bool,
pub zoom: u16,
pub cond_formats: Vec<CondFormat>,
@@ -113,6 +117,10 @@ pub struct Sheet {
pub preserved: Option<Arc<crate::preserved::Preserved>>,
}
fn yes() -> bool {
true
}
/// Default width of a column and height of a row (pixels at 96 dpi) for the default font.
pub const DEFAULT_COL_WIDTH: f32 = 64.0;
pub const DEFAULT_ROW_HEIGHT: f32 = 20.0;
@@ -140,6 +148,7 @@ impl Sheet {
tab_color: None,
visibility: Visibility::Visible,
show_gridlines: true,
show_outline_symbols: true,
show_headings: true,
show_formulas: false,
show_zeros: true,
+6 -1
View File
@@ -79,6 +79,9 @@ fn main() {
}
i += 1;
}
// Catalog the installed fonts first (the app scans in the background from its first frame), so
// the workbook's fonts are drawn in their own faces, not a substitute.
let _ = gridcraft_ui_egui::fonts::catalog();
let mut session = Session::new();
if let Some(p) = &input {
session.execute("file.open", json!({"path": p})).expect("open");
@@ -127,7 +130,9 @@ fn main() {
app
},
);
harness.run_steps(8);
// The workbook's installed fonts bind once the font scan is done; frames past the first few
// apply them, so the snapshot shows them as the app does.
harness.run_steps(14);
let img = harness.render().expect("render");
img.save(&out).expect("save png");
eprintln!("wrote {out} ({}x{})", img.width(), img.height());
+1 -4
View File
@@ -1084,11 +1084,8 @@ mod tests {
}
assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
// Other languages put the count where their own words want it.
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa");
for lang in Language::ALL {
assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY));
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
let words = age(lang, seconds);
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
-7
View File
@@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> {
Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() }))
}
/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the
/// function help tables are checked against them.
#[cfg(test)]
pub(crate) fn argument_names(signature: &str) -> Vec<&str> {
split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default()
}
/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's
/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`).
fn argument_name(arg: &str) -> &str {
+211 -19
View File
@@ -102,6 +102,19 @@ pub enum Drag {
},
}
/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo
/// another way. It only applies while nothing else changed the workbook (`revision`) and the
/// filled cells are still selected.
#[derive(Clone, Debug, PartialEq)]
pub struct FillTag {
pub src: RangeRef,
pub target: RangeRef,
pub uid: u64,
pub sheet: usize,
pub revision: u64,
pub undo_len: usize,
}
#[derive(Default)]
pub struct GridState {
pub drag: Drag,
@@ -109,6 +122,10 @@ pub struct GridState {
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
/// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves).
pub reveal_moving_end: bool,
/// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when
/// extending), set only by those keys. A move from the scrolled rows or columns into the
/// frozen ones scrolls them back to their top or left.
pub reveal_from: Option<CellRef>,
pub last_title: Option<String>,
pub drag_select: bool,
pub rect: Option<Rect>,
@@ -120,6 +137,8 @@ pub struct GridState {
pub context_menu_frame: u64,
/// After a right-drag of the fill handle: the source, the dragged range and where to open the menu.
pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>,
/// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells.
pub fill_tag: Option<FillTag>,
/// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id.
pub object_menu: Option<(Pos2, &'static str, u32)>,
pub marching_phase: f32,
@@ -235,13 +254,15 @@ impl Geo {
let frozen_h = (ri.start(fr) as f32) * z;
let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
let mut band_w = crate::outline::band(row_outline.levels, zoom);
let mut band_h = crate::outline::band(col_outline.levels, zoom);
// Show Outline Symbols off: no bands (the outline's hidden lines stay hidden).
let symbols = sh.show_outline_symbols;
let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 };
let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 };
// Without headings the level buttons still need a corner to sit in.
if row_outline.levels > 0 && header_h == 0.0 {
if symbols && row_outline.levels > 0 && header_h == 0.0 {
band_h = band_h.max(crate::outline::lane(zoom) + 3.0);
}
if col_outline.levels > 0 && header_w == 0.0 {
if symbols && col_outline.levels > 0 && header_w == 0.0 {
band_w = band_w.max(crate::outline::lane(zoom) + 3.0);
}
let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max);
@@ -681,6 +702,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
if app.grid.ensure_visible {
app.grid.ensure_visible = false;
let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active };
let from = app.grid.reveal_from.take();
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell);
if paper {
crate::paper_view::ensure_covers(app, &wb, si, target);
@@ -694,6 +716,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
} else if cr.bottom() > area.bottom() - 2.0 {
view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z;
}
} else if from.is_some_and(|f| f.row >= geo.fr) {
// A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen
// row 1) brings the scrolled rows back to their top; the rows it passed over would
// otherwise stay out of sight.
view.scroll.y = 0.0;
}
if target.col >= geo.fc {
if cr.left() < area.left() {
@@ -701,6 +728,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
} else if cr.right() > area.right() - 2.0 {
view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left());
}
} else if from.is_some_and(|f| f.col >= geo.fc) {
view.scroll.x = 0.0;
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
geo = make_geo(app, view.scroll);
@@ -790,6 +819,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
context_menu(app, ui);
fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu);
fill_options_tag(app, ui, &geo, sh);
object_menu(app, ui);
error_check_tag(app, ui, &geo, sh);
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
@@ -1268,13 +1298,18 @@ pub(crate) fn paint_quadrant(
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
let mut w = galley.size().x;
// Numbers and dates never overflow: they show #### when too narrow.
if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill;
if hashed {
// Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell.
let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0);
let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize;
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
w = galley.size().x;
}
// Repeat fill (`*` in number formats) / Fill alignment.
// Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps
// it: rebuilding it from the text would overflow and clip its leading digits.
if let Some((ch, pos)) = fill_char
&& !hashed
&& pos <= first_line.len()
{
let base_w = w;
@@ -2353,6 +2388,20 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
&& !mods.shift
{
app.grid.last_click = None;
// A PivotTable's value cell shows its source records on a new sheet (Excel's Show
// Details); its labels and headers still edit.
if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) {
let not_a_value_cell =
gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string();
match app.run("pivot.showDetails", json!({"cell": c.a1()})) {
Ok(_) => return,
Err(e) if e == not_a_value_cell => {}
Err(e) => {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
return;
}
}
}
// Following a hyperlink requires a single click in Excel; double-click edits.
app.begin_edit(None, false);
return;
@@ -2364,7 +2413,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
d.selection.anchor = sel.anchor;
}
} else if mods.command {
select_ranges(app, RangeRef::cell(c), true, c);
// Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& !mods.alt
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
{
follow_link(app, &h.target);
} else {
select_ranges(app, RangeRef::cell(c), true, c);
}
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
@@ -2533,8 +2591,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if t != src {
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
let mode = if mods.command { "alternate" } else { "series" };
if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
Ok(_) => set_fill_tag(app, src, t),
Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))),
}
}
}
@@ -2967,9 +3026,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
let cmd = m.command;
let shift = m.shift;
let mv = |app: &mut SheetApp, dr: i64, dc: i64| {
let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active });
let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd}));
app.grid.ensure_visible = true;
app.grid.reveal_moving_end = shift;
app.grid.reveal_from = from;
};
match key {
Key::ArrowUp => mv(app, -1, 0),
@@ -3043,6 +3104,19 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
}
}
/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so
/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom,
/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px.
fn editor_slack(size: f32) -> f32 {
14.0 + size
}
/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the
/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies.
fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 {
if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) }
}
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
let Some(ed) = app.editor.as_ref() else { return };
if ed.sheet != wb.active_sheet {
@@ -3069,7 +3143,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
// Excel keeps the cell's height for one line and only grows for extra lines.
let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() };
((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), h)
(editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h)
};
// Grows to the left on a right-to-left sheet.
let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));
@@ -3317,6 +3391,86 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
}
}
/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them.
const FILL_MODES: [(&str, &str); 4] =
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
app.grid.fill_tag =
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() });
}
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only
/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
/// The button goes away as soon as anything else changes the workbook or the selection moves.
fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) {
let Some(tag) = app.grid.fill_tag.clone() else { return };
let current = app.session.active().is_some_and(|d| {
d.uid == tag.uid
&& d.wb.active_sheet == tag.sheet
&& d.revision == tag.revision
&& d.undo.len() == tag.undo_len
&& d.selection.ranges.len() == 1
&& d.selection.ranges.first().is_some_and(|r| *r == tag.target)
});
if !current {
app.grid.fill_tag = None;
return;
}
if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None {
return;
}
let size = vec2(18.0, 18.0);
let corner = geo.range_rect(sh, tag.target).right_bottom();
let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size));
// `button` is in screen space, so compare it with the mirrored cells area.
if !geo.mirror_rect(geo.cells).contains_rect(button) {
return;
}
let lang = crate::i18n::current(ui.ctx());
let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options")));
let t = Tokens::get(ui.ctx());
let p = ui.painter();
p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window });
p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
// Two small cells and a drop-down arrow.
let c = button.center();
p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
p.add(egui::Shape::convex_polygon(
vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)],
t.text,
Stroke::NONE,
));
let mut chosen = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(190.0);
for (label, mode) in FILL_MODES {
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
chosen = Some(mode);
ui.close();
}
}
});
if let Some(mode) = chosen {
if let Err(e) = app.run("edit.undo", json!({})) {
app.grid.fill_tag = None;
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
return;
}
match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) {
Ok(_) => set_fill_tag(app, tag.src, tag.target),
Err(e) => {
app.grid.fill_tag = None;
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
}
}
/// The menu a right-drag of the fill handle opens on release, as in Excel.
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
let Some((src, target, at)) = app.grid.fill_menu else { return };
@@ -3324,14 +3478,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
let items = [
("Copy Cells", "copy"),
("Fill Series", "extend"),
("Fill Formatting Only", "formats"),
("Fill Without Formatting", "values"),
("-", ""),
("Series…", "ui:series"),
];
let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")];
let lang = crate::i18n::current(ui.ctx());
for (label, mode) in items {
if label == "-" {
@@ -3886,6 +4033,23 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 {
#[cfg(test)]
mod tests {
#[test]
fn editor_matches_the_cell_while_the_text_fits() {
use super::editor_width;
assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0);
assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0);
assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0);
}
#[test]
fn editor_slack_fits_one_more_glyph_at_high_zoom() {
use super::editor_slack;
// A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px.
for size in [14.7_f32, 25.0, 60.0] {
assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size));
}
}
#[test]
fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() {
use super::{HAlign, painted_halign};
@@ -4216,6 +4380,34 @@ mod pane_tests {
assert!(range_in_pane(sel, (2, 20), (2, 10)));
}
#[test]
fn hidden_outline_symbols_take_no_room() {
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
let mut sh = Sheet::new("S");
sh.rows.entry(3).or_default().outline = 1;
sh.cols.entry(2).or_default().outline = 2;
let shown = Geo::new(&sh, rect, vec2(0.0, 0.0));
assert!(shown.band_w > 0.0 && shown.band_h > 0.0);
sh.show_outline_symbols = false;
let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0));
assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0));
assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top());
// Without headings too.
sh.show_headings = false;
let bare = Geo::new(&sh, rect, vec2(0.0, 0.0));
assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0));
// No button answers a click on the empty bands' edges (the grid's left and top), where the
// level buttons would sit with no headings to hold them.
for geo in [&hidden, &bare] {
for i in 0..400 {
let t = i as f32 * 0.5;
for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] {
assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}");
}
}
}
}
/// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns.
fn frozen_geo(sh: &mut Sheet) -> Geo {
sh.freeze = Some((2, 2));
+19 -191
View File
@@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String {
mod tests {
use super::*;
/// Help tables are sorted, unique, and keyed by text the function catalog still has.
/// Help tables are sorted and unique: lookups binary-search them.
#[test]
fn function_help_is_sorted_and_keyed_by_current_catalog_text() {
// Lookups binary-search the tables. A key that no longer matches the catalog (an English
// description reworded, an argument renamed) would quietly show English again.
let catalog = gridcraft_engine::cmd::formulas::function_list();
let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
let arguments: std::collections::HashSet<&str> =
catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect();
fn function_help_tables_are_sorted_and_unique() {
for language in Language::ALL {
let Some((description_table, argument_table)) = language.function_help() else { continue };
for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] {
for table in [description_table, argument_table] {
assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique");
for (english, translated) in table {
assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use");
assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}");
}
}
@@ -343,105 +336,27 @@ mod tests {
}
#[test]
fn translations_are_unique_and_preserve_unknown_text() {
for (i, (en, ja)) in ja::CORE.iter().enumerate() {
assert!(!ja.is_empty());
assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label));
}
fn untranslated_text_falls_back_to_english() {
let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label);
for (english, _) in ja::CORE {
assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}");
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}");
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}");
assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}");
assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}");
assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}");
assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}");
assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}");
assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}");
assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}");
assert_eq!(Language::En.tr(english), label(english));
}
assert_eq!(Language::Ja.tr("Data"), "データ");
assert_eq!(Language::Zh.tr("Data"), "数据");
assert_eq!(Language::Ko.tr("Data"), "데이터");
assert_eq!(Language::Ru.tr("Data"), "Данные");
assert_eq!(Language::PtBr.tr("Data"), "Dados");
assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro.");
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro.");
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
assert_eq!(Language::Es.tr("Data"), "Datos");
assert_eq!(Language::El.tr("Data"), "Δεδομένα");
assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας");
assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}");
assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles");
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::De.tr("Data"), "Daten");
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Fr.tr("Data"), "Données");
assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Tr.tr("Data"), "Veri");
assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::PtBr.tr("Date"), "Data");
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
// Czech "Data" is spelled as in English, so probe it with a label that differs.
assert_eq!(Language::Cs.tr("Data"), "Data");
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Tr.tr("Formulas"), "Formüller");
// A `context|label` key shows only its label in English and splits the translation.
for language in Language::ALL {
assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated");
}
// A `context|label` key shows only its label in English.
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き");
assert_eq!(Language::Ja.tr("Orientation"), "方向");
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
assert_eq!(Language::Sr.tr("Data"), "Podaci");
assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija");
assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija");
assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü");
assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme");
assert_eq!(Language::Pl.tr("Data"), "Dane");
assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja");
assert_eq!(Language::It.tr("Data"), "Dati");
assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri");
assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento");
assert_eq!(Language::It.tr("Orientation"), "Orientamento");
assert_eq!(Language::Uk.tr("Data"), "Дані");
assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Vi.tr("Data"), "Dữ liệu");
assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy");
assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ");
assert_eq!(Language::Nl.tr("Data"), "Gegevens");
assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand");
assert_eq!(Language::Nl.tr("Orientation"), "Richting");
assert_eq!(Language::Ro.tr("Data"), "Date");
assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare");
assert_eq!(Language::Ro.tr("Orientation"), "Orientare text");
// Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole.
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first."));
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
assert_eq!(Language::parse("xx"), None);
}
/// A catalog whose translations slip onto their neighbours' keys still covers every key, so
/// check the shape of each translation against its English: placeholders survive, and a
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French
/// once shipped with over a hundred translations shifted this way ("Close" read "Société").
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label.
#[test]
fn translations_keep_the_shape_of_their_english() {
let placeholders =
@@ -457,106 +372,19 @@ mod tests {
}
}
}
for (english, french) in [
("Close", "Fermer"),
("File", "Fichier"),
("Save", "Enregistrer"),
("Animations", "Animations"),
("Company", "Société"),
("Show Detail", "Afficher le détail"),
("Solver", "Solveur"),
] {
assert_eq!(Language::Fr.tr(english), french);
}
}
#[test]
fn german_cell_and_header_context_menus_are_fully_translated() {
for label in [
"Cut",
"Copy",
"Paste",
"Paste Special…",
"Insert…",
"Delete…",
"Clear Contents",
"Filter by Selected Cell's Value",
"Sort A to Z",
"Sort Z to A",
"New Comment",
"New Note",
"Format Cells…",
"Pick From Drop-down List…",
"Define Name…",
"Insert Copied Cells",
"Insert Copied Cells…",
"Insert Cut Cells",
"Insert",
"Delete",
"Row Height…",
"AutoFit Row Height",
"Column Width…",
"AutoFit Column Width",
"Hide",
"Unhide",
"Group",
] {
assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}");
}
}
#[test]
fn sheet_tab_menu_and_autosave_labels_are_translated() {
let labels = [
"Insert Sheet",
"Delete",
"Rename",
"Move or Copy…",
"Duplicate",
"Tab Color",
"No Color",
"Hide",
"Unhide…",
"Protect Sheet…",
"Unprotect Sheet…",
"Select All Sheets",
"AutoSave",
"AutoSave: save after every change (workbooks saved to a file)",
"Previous sheet",
"Next sheet",
"All sheets",
"New sheet (⇧F11)",
];
for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] {
for label in labels {
assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code());
}
}
assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado");
}
#[test]
fn translations_never_show_the_context_prefix() {
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
// value shows it on screen (File › Options read "Options|Général" in French).
for language in Language::ALL {
for (english, _) in ja::CORE {
let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]);
for english in keys {
for language in Language::ALL {
let shown = language.tr(english);
assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code());
}
}
assert_eq!(Language::Fr.tr("Options|General"), "Général");
assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer");
assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage");
assert_eq!(Language::Sr.tr("Options|General"), "Opšte");
assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj");
assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz");
assert_eq!(Language::Nl.tr("Options|General"), "Algemeen");
assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan");
assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven");
assert_eq!(Language::Ro.tr("Options|General"), "General");
assert_eq!(Language::Ro.tr("Options|Save"), "Salvare");
assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare");
}
#[test]
+168 -121
View File
@@ -1,12 +1,12 @@
//! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's
//! Japanese wording, and anything absent falls back to English.
//!
//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs,
//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels,
//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian
//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap.
//! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog
//! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items,
//! template names and native-menu strings. The two lists never overlap. A string a language doesn't
//! translate shows in English; `cargo xtask i18n-report` lists them per language.
/// English label → Japanese, the shared reference set every catalog covers.
/// English label → Japanese, the shared reference set.
pub(super) const CORE: &[(&str, &str)] = &[
("Paste", "貼り付け"),
("Paste Values", "値の貼り付け"),
@@ -980,10 +980,163 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
("Unprotect Sheet…", "シート保護の解除…"),
];
/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the
/// Portuguese catalog has no entry for them either). Kept apart so the two catalogs above keep the
/// same key set; it never overlaps them.
pub(super) const EXTRA: &[(&str, &str)] = &[
("Cut (⌘X)", "切り取り (⌘X)"),
("Copy (⌘C)", "コピー (⌘C)"),
("Bold (⌘B)", "太字 (⌘B)"),
("Italic (⌘I)", "斜体 (⌘I)"),
("Underline (⌘U)", "下線 (⌘U)"),
("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"),
("Filter (⌘⇧F)", "フィルター (⌘⇧F)"),
("Flash Fill (⌘E)", "フラッシュ フィル (⌘E)"),
("Insert Link (⌘K)", "リンクの挿入 (⌘K)"),
("Paste (⌘V)", "貼り付け (⌘V)"),
("Redo (⌘Y)", "やり直し (⌘Y)"),
("Save (⌘S)", "上書き保存 (⌘S)"),
("Search commands (⌘⇧Space)", "コマンドの検索 (⌘⇧Space)"),
("Table (⌘T)", "テーブル (⌘T)"),
("Undo (⌘Z)", "元に戻す (⌘Z)"),
("New Workbook", "新しいブック"),
("Save As…", "名前を付けて保存…"),
("Print…", "印刷…"),
("Export as PDF…", "PDF としてエクスポート…"),
("Save a Copy as Excel Workbook (.xlsx)…", "Excel ブック (.xlsx) としてコピーを保存…"),
("Export as CSV…", "CSV としてエクスポート…"),
("Export as Web Page (.html)…", "Web ページ (.html) としてエクスポート…"),
("Bottom Border", "下罫線"),
("Top Border", "上罫線"),
("Left Border", "左罫線"),
("Right Border", "右罫線"),
("No Border", "枠なし"),
("All Borders", "格子"),
("Outside Borders", "外枠"),
("Thick Outside Borders", "太い外枠"),
("Bottom Double Border", "下二重罫線"),
("Thick Bottom Border", "下太罫線"),
("Top and Bottom Border", "上下罫線"),
("Top and Thick Bottom Border", "上罫線 + 下太罫線"),
("Top and Double Bottom Border", "上罫線 + 下二重罫線"),
("Inside Borders", "内側罫線"),
("More Borders…", "その他の罫線…"),
("More Functions…", "その他の関数…"),
("No Fill", "塗りつぶしなし"),
("Theme Colors", "テーマの色"),
("Standard Colors", "標準の色"),
("Highlight Cells Rules", "セルの強調表示ルール"),
("Greater Than…", "指定の値より大きい…"),
("Less Than…", "指定の値より小さい…"),
("Between…", "指定の範囲内…"),
("Equal To…", "指定の値に等しい…"),
("Text that Contains…", "文字列…"),
("A Date Occurring…", "日付…"),
("Duplicate Values…", "重複する値…"),
("Top/Bottom Rules", "上位/下位ルール"),
("Blue", "青"),
("Green", "緑"),
("Red", "赤"),
("Orange", "オレンジ"),
("Light Blue", "水色"),
("Purple", "紫"),
("Green - Yellow - Red", "緑 - 黄 - 赤"),
("Red - Yellow - Green", "赤 - 黄 - 緑"),
("Green - White - Red", "緑 - 白 - 赤"),
("Blue - White - Red", "青 - 白 - 赤"),
("White - Green", "白 - 緑"),
("White - Red", "白 - 赤"),
("3 Arrows", "3 つの矢印"),
("3 Traffic Lights", "3 つの信号"),
("3 Symbols", "3 つの記号"),
("4 Ratings", "4 段階評価"),
("5 Arrows", "5 つの矢印"),
("5 Quarters", "5 つの四分円"),
("New Rule…", "新しいルール…"),
("Clear Rules", "ルールのクリア"),
("Manage Rules…", "ルールの管理…"),
("Good, Bad and Neutral", "良い、悪い、どちらでもない"),
("Bad", "悪い"),
("Good", "良い"),
("Neutral", "どちらでもない"),
("Data and Model", "データとモデル"),
("Calculation", "計算"),
("Check Cell", "チェック セル"),
("Explanatory Text", "説明文"),
("Input", "入力"),
("Linked Cell", "リンク セル"),
("Note", "メモ"),
("Output", "出力"),
("Warning Text", "警告文"),
("Titles and Headings", "タイトルと見出し"),
("Heading 1", "見出し 1"),
("Heading 2", "見出し 2"),
("Heading 3", "見出し 3"),
("Heading 4", "見出し 4"),
("Total", "集計"),
("Themed Cell Styles", "テーマのセルのスタイル"),
("Number Format", "表示形式"),
("Comma [0]", "桁区切り [0]"),
("Currency [0]", "通貨表示形式 [0]"),
("Percent", "パーセント"),
("Place in Cell", "セル内に配置"),
("Place over Cells", "セルの上に配置"),
("Rectangle", "四角形"),
("Rounded Rectangle", "角丸四角形"),
("Oval", "楕円"),
("Triangle", "三角形"),
("Arrow", "矢印"),
("Column", "縦棒"),
("Win/Loss", "勝敗"),
("Type to filter…", "入力して絞り込み…"),
("Sort Ascending", "昇順"),
("Sort Descending", "降順"),
("Search", "検索"),
("Apply", "適用"),
("20% - Accent1", "20% - アクセント 1"),
("20% - Accent2", "20% - アクセント 2"),
("20% - Accent3", "20% - アクセント 3"),
("20% - Accent4", "20% - アクセント 4"),
("20% - Accent5", "20% - アクセント 5"),
("20% - Accent6", "20% - アクセント 6"),
("40% - Accent1", "40% - アクセント 1"),
("40% - Accent2", "40% - アクセント 2"),
("40% - Accent3", "40% - アクセント 3"),
("40% - Accent4", "40% - アクセント 4"),
("40% - Accent5", "40% - アクセント 5"),
("40% - Accent6", "40% - アクセント 6"),
("60% - Accent1", "60% - アクセント 1"),
("60% - Accent2", "60% - アクセント 2"),
("60% - Accent3", "60% - アクセント 3"),
("60% - Accent4", "60% - アクセント 4"),
("60% - Accent5", "60% - アクセント 5"),
("60% - Accent6", "60% - アクセント 6"),
("Accent1", "アクセント 1"),
("Accent2", "アクセント 2"),
("Accent3", "アクセント 3"),
("Accent4", "アクセント 4"),
("Accent5", "アクセント 5"),
("Accent6", "アクセント 6"),
("Recent Colors", "最近使用した色"),
("More Colors…", "その他の色…"),
("Hex", "16進"),
("Page Break Preview", "改ページ プレビュー"),
("Bubble", "バブル"),
("Box & Whisker", "箱ひげ図"),
("Treemap", "ツリーマップ"),
("Sunburst", "サンバースト"),
("Stock", "株価"),
("Bar", "横棒"),
(
"Drag to fill cells. Drag from inside a cell to select a range.",
"ドラッグしてセルをオートフィルします。セルの内側からドラッグすると範囲を選択します。",
),
];
/// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can
/// fall back to English. Dialog strings come first, the shared core second.
pub(super) fn translate(text: &str) -> Option<&'static str> {
DIALOGS.iter().chain(CORE).find(|(english, _)| *english == text).map(|(_, translated)| *translated)
DIALOGS.iter().chain(CORE).chain(EXTRA).find(|(english, _)| *english == text).map(|(_, translated)| *translated)
}
#[cfg(test)]
@@ -997,130 +1150,24 @@ mod tests {
DIALOGS.iter().chain(CORE).map(|(english, _)| *english)
}
/// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and
/// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are
/// decoded, so the keys compare equal to the ones in the tables above.
fn string_literal(source: &str, start: usize) -> Option<(String, usize)> {
if !source[start..].starts_with('"') {
return None;
}
let mut value = String::new();
let mut chars = source[start + 1..].char_indices();
while let Some((offset, c)) = chars.next() {
match c {
'"' => return Some((value, start + 1 + offset + 1)),
'\\' => match chars.next().map(|(_, c)| c) {
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
Some('r') => value.push('\r'),
Some('0') => value.push('\0'),
Some('\\') => value.push('\\'),
Some('"') => value.push('"'),
Some('\'') => value.push('\''),
Some('u') => {
let mut hex = String::new();
for (_, c) in chars.by_ref() {
if c == '}' {
break;
}
if c != '{' {
hex.push(c);
}
}
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
value.push(c);
}
}
// A line continuation (backslash, newline, indentation) adds nothing.
_ => {}
},
_ => value.push(c),
}
}
None
}
/// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a
/// string literal whose `=>` is followed by another string literal, which covers the block form
/// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from
/// drifting apart again instead of checking a remembered count.
fn portuguese_keys() -> Vec<String> {
let source = include_str!("pt.rs");
let mut literals: Vec<(String, usize, usize)> = Vec::new();
let mut i = 0;
while i < source.len() {
let rest = &source[i..];
if rest.starts_with("//") {
i += rest.find('\n').unwrap_or(rest.len());
continue;
}
if rest.starts_with('"') {
let (value, end) = string_literal(source, i).unwrap();
literals.push((value, i, end));
i = end;
continue;
}
i += rest.chars().next().unwrap().len_utf8();
}
let mut keys = Vec::new();
for pair in literals.windows(2) {
let (key, _, end) = &pair[0];
let start = pair[1].1;
let gap = source[*end..start].trim();
if gap == "=>" || gap == "=> {" {
keys.push(key.clone());
}
}
keys
}
#[test]
fn keys_are_unique_and_every_value_is_translated() {
fn keys_are_unique_and_no_value_is_blank() {
let mut seen = HashSet::new();
for (english, japanese) in DIALOGS.iter().chain(CORE) {
assert!(!japanese.is_empty(), "empty translation for {english:?}");
assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}");
assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}");
}
assert_eq!(seen.len(), catalog_keys().count());
assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers");
assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it");
assert_eq!(catalog_keys().count(), 954);
}
#[test]
fn covers_every_key_the_portuguese_catalog_translates() {
let portuguese = portuguese_keys();
assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count");
let japanese: HashSet<&str> = catalog_keys().collect();
for key in &portuguese {
assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not");
fn extra_labels_do_not_overlap_the_catalog() {
let catalog: HashSet<&str> = catalog_keys().collect();
let mut seen = HashSet::new();
for (english, japanese) in EXTRA {
assert!(!japanese.trim().is_empty(), "empty translation for {english:?}");
assert!(!catalog.contains(english), "{english:?} is already in CORE or DIALOGS");
assert!(seen.insert(*english), "duplicate EXTRA key: {english:?}");
assert_eq!(translate(english), Some(*japanese));
}
assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set");
}
#[test]
fn high_value_strings_use_excel_japanese_wording() {
for (english, japanese) in [
("OK", "OK"),
("Cancel", "キャンセル"),
("Don't Save", "保存しない"),
("Text Box", "テキスト ボックス"),
("Row Height", "行の高さ"),
("Column Width", "列の幅"),
("Sort", "並べ替え"),
("Filter", "フィルター"),
("Conditional Formatting", "条件付き書式"),
("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"),
("Save a copy as .{format}", ".{format} としてコピーを保存"),
("Format Cells|Fill", "塗りつぶし"),
("Insert\nSlicer", "スライサーの\n挿入"),
] {
assert_eq!(translate(english), Some(japanese), "{english:?}");
assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}");
}
// A `context|label` key shows only its label in English; an unknown label keeps the text.
assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill");
assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog");
}
}
+25 -1
View File
@@ -1005,7 +1005,31 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Print job submitted: {receipt}" => "Lucrarea de imprimare a fost trimisă: {receipt}",
"Print failed: {error}" => "Imprimarea a eșuat: {error}",
"Print failed: the printing worker stopped." => "Imprimarea a eșuat: procesul de imprimare s-a oprit.",
"Options|Editing" => "Editing",
"Options|Editing" => "Editare",
"Duplicate" => "Duplicare",
"Rename" => "Redenumire",
"Product Information" => "Informații despre produs",
"Tab Color" => "Culoare filă",
"Select All Sheets" => "Selectare toate foile",
"Fit selection" => "Încadrare selecție",
"All sheets" => "Toate foile",
"Version" => "Versiune",
"Unhide…" => "Reafișare…",
"Platform" => "Platformă",
"Unprotect Sheet…" => "Deprotejare foaie…",
"AutoSave: save after every change (workbooks saved to a file)" => {
"Salvare automată: salvează după fiecare modificare (registrele salvate într-un fișier)"
}
"Licence" => "Licență",
"Account" => "Cont",
"No Color" => "Fără culoare",
"Move or Copy…" => "Mutare sau copiere…",
"Number Stored as Text" => "Număr stocat ca text",
"Select Data…" => "Selectare date…",
"Convert to Number" => "Conversie în număr",
"Format Chart Area…" => "Formatare zonă diagramă…",
"Error checking options" => "Opțiuni de verificare a erorilor",
"Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate",
_ => return None,
})
}
+2
View File
@@ -223,9 +223,11 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0);
let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64;
let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols };
let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active });
let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift}));
app.grid.ensure_visible = true;
app.grid.reveal_moving_end = m.shift;
app.grid.reveal_from = from;
}
Shortcut::Edit => app.begin_edit(None, false),
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
+8
View File
@@ -117,6 +117,14 @@ impl Default for UiState {
}
impl UiState {
/// The state saved by an earlier run, as the app starts with it. The ribbon tab is written to
/// the preferences but not restored: Excel opens on Home every launch, so a contextual tab
/// (Table Design) cannot come back with nothing selected to justify it.
pub fn at_startup(mut self) -> Self {
self.ribbon_tab = "Home".into();
self
}
pub fn theme_preference(&self) -> egui::ThemePreference {
if self.system_theme {
egui::ThemePreference::System
+4
View File
@@ -224,6 +224,10 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
/// The button under `p` (logical coordinates), if any.
pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option<Action> {
// Hidden symbols (or no outline) leave empty bands: a click on their edge is a cell click.
if geo.band_w <= 0.0 && geo.band_h <= 0.0 {
return None;
}
let (rows, cols) = bands(geo);
if !rows.contains(p) && !cols.contains(p) {
return None;
+7
View File
@@ -204,6 +204,11 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let collapsed = app.ui.ribbon_collapsed;
let h = if collapsed { 34.0 } else { 34.0 + 92.0 };
// A contextual tab exists only while its object is selected (Excel hides Table Design once the
// cell leaves the table); a remembered one that is gone would draw an empty ribbon.
if CONTEXTUAL_TABS.contains(&app.ui.ribbon_tab.as_str()) && !contextual_tabs(app).contains(&app.ui.ribbon_tab.as_str()) {
app.ui.ribbon_tab = "Home".to_string();
}
egui::Panel::top("ribbon")
.exact_size(h)
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 }))
@@ -322,6 +327,8 @@ fn theme_choices(app: &mut SheetApp, ui: &mut Ui) {
}
}
const CONTEXTUAL_TABS: [&str; 3] = ["Table Design", "Chart Design", crate::paper_view::TAB];
fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
let mut v = Vec::new();
if let Some(d) = app.session.active()
@@ -0,0 +1,77 @@
//! Repeat fill (`*` in a number format, as in the Accounting format): a number that fits is drawn
//! across its cell, and one too wide for its column shows #### instead of losing its leading digits.
use egui::Shape;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
const ACCOUNTING: &str = r#"_-* #,##0.00\ "CHF"_-;\-* #,##0.00\ "CHF"_-;_-* "-"??\ "CHF"_-;_-@_-"#;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B2", "values": [[344.75, 344.75], [2509, 2509]]})).unwrap();
s.execute("home.formatCells", json!({"range": "A1:B2", "style": {"num_fmt": ACCOUNTING}})).unwrap();
// Column A is too narrow for 2,509.00 CHF; column B fits both.
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap();
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap();
s.execute("selection.set", json!({"range": "H20"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
/// Painted text shapes whose text satisfies `f`: (text, left x, width).
fn texts(h: &Harness, f: impl Fn(&str) -> bool) -> Vec<(String, f32, f32)> {
h.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
Shape::Text(t) if f(&t.galley.job.text) => Some((t.galley.job.text.clone(), t.pos.x, t.galley.size().x)),
_ => None,
})
.collect()
}
#[test]
fn too_wide_fill_number_shows_hashes_not_a_clipped_value() {
let h = harness();
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let a2 = geo.cell_rect(sheet, CellRef::parse("A2").unwrap());
let b2 = geo.cell_rect(sheet, CellRef::parse("B2").unwrap());
// A2 is drawn as #### inside its cell.
let hashes = texts(&h, |t| !t.is_empty() && t.chars().all(|c| c == '#'));
assert!(hashes.iter().any(|(_, x, w)| *x >= a2.left() && x + w <= a2.right() + 0.5), "A2 shows ####: {hashes:?}");
// The only 2,509.00 drawn is B2's, whole and inside column B.
let values = texts(&h, |t| t.contains("509.00"));
assert_eq!(values.len(), 1, "{values:?}");
let (text, x, w) = &values[0];
assert!(text.contains("2,509.00 CHF"), "{text:?}");
assert!(*x >= b2.left() && x + w <= b2.right() + 0.5, "B2's value stays in its cell: {values:?}");
}
#[test]
fn fill_spreads_a_fitting_number_across_its_cell() {
let h = harness();
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let b1 = geo.cell_rect(sheet, CellRef::parse("B1").unwrap());
let drawn = texts(&h, |t| t.contains("344.75 CHF"));
let (_, x, w) = drawn.iter().find(|(_, x, _)| *x >= b1.left()).expect("B1 is drawn");
// The fill pushes the value to the right edge (less the trailing `_-` space).
assert!(b1.right() - (x + w) < 6.0, "B1 ends at its right edge: {drawn:?} in {b1:?}");
assert!(*x - b1.left() < 6.0, "the fill starts at the left edge: {drawn:?} in {b1:?}");
}
@@ -0,0 +1,94 @@
//! A contextual ribbon tab (Table Design, Chart Design) only exists while its object is selected.
//! The tab remembered in the saved UI state, or left behind when the selection moves away, must
//! not leave the ribbon blank: it falls back to Home.
use egui::vec2;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn session_with_table() -> Session {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
session.execute("selection.set", json!({"range": "A2"})).unwrap();
session
}
fn app_restored_on(tab: &str, session: Session) -> Harness {
let mut app = SheetApp::new(session, Default::default());
app.ui.ribbon_tab = tab.into();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
#[test]
fn a_saved_table_design_tab_without_a_table_opens_on_home() {
let mut session = Session::new();
session.new_workbook();
let h = app_restored_on("Table Design", session);
assert_eq!(h.state().ui.ribbon_tab, "Home");
}
#[test]
fn a_saved_chart_design_tab_without_a_chart_opens_on_home() {
let mut session = Session::new();
session.new_workbook();
let h = app_restored_on("Chart Design", session);
assert_eq!(h.state().ui.ribbon_tab, "Home");
}
#[test]
fn leaving_the_table_while_on_table_design_returns_to_home() {
let mut h = harness(session_with_table());
h.state_mut().ui.ribbon_tab = "Table Design".into();
h.run_steps(2);
assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the tab stays while the cell is in the table");
h.state_mut().session.execute("selection.set", json!({"range": "D10"})).unwrap();
h.run_steps(2);
assert_eq!(h.state().ui.ribbon_tab, "Home");
}
#[test]
fn startup_opens_on_home_not_the_saved_tab() {
let saved = gridcraft_ui_egui::UiState { ribbon_tab: "Table Design".into(), dark: true, ..Default::default() };
let json = serde_json::to_string(&saved).unwrap();
assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}");
let restored = serde_json::from_str::<gridcraft_ui_egui::UiState>(&json).unwrap().at_startup();
assert_eq!(restored.ribbon_tab, "Home", "the saved tab is not restored");
assert!(restored.dark, "the other preferences are");
}
#[test]
fn an_ordinary_tab_stays_put_in_a_session() {
let mut session = Session::new();
session.new_workbook();
let mut h = harness(session);
h.state_mut().ui.ribbon_tab = "Data".into();
h.run_steps(3);
assert_eq!(h.state().ui.ribbon_tab, "Data");
}
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::{Session, core::CellRef, core::Value};
use gridcraft_ui_egui::i18n::Language;
use gridcraft_ui_egui::{Services, SheetApp};
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
@@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
#[test]
fn translated_options_headings_show_no_context_prefix() {
for (code, headings, prefixed, (view, view_prefixed)) in [
("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")),
("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")),
("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")),
("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")),
] {
for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] {
let mut h = harness(Services::default());
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
h.run_steps(3);
assert_eq!(page(&h), Some("options"));
for heading in headings {
assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading");
}
for label in prefixed {
assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
for key in ["Options|General", "Options|Save"] {
let heading = language.tr(key);
assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}");
assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading");
}
// Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same
// prefix. The View tab may share the word, so count at least the two checkboxes.
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
h.state_mut().ui.ribbon_tab = "Page Layout".into();
h.run_steps(3);
assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context");
assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}");
let view = language.tr("Sheet Options|View");
assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}");
assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}");
}
}

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