mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ee8c0d15f | ||
|
|
bc0cd89225 | ||
|
|
524ce912e5 | ||
|
|
ebc986fcc9 |
+33
-13
@@ -1,7 +1,8 @@
|
||||
# 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]
|
||||
@@ -11,18 +12,37 @@ 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.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
# (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]
|
||||
@@ -13,8 +10,6 @@ on:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "57 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -11,8 +11,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
+1
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-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,8 +135,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
||||
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||
| 2026-10-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 |
|
||||
@@ -149,13 +147,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
|
||||
| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
|
||||
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
|
||||
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
|
||||
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
|
||||
@@ -226,8 +221,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
| 2026-10-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 |
|
||||
@@ -237,8 +230,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
|
||||
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
|
||||
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
|
||||
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
|
||||
|
||||
@@ -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.at_startup();
|
||||
app.ui = state;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Some(s) = prefs.filter(readable)
|
||||
|
||||
@@ -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.at_startup();
|
||||
app.ui = ui;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
|
||||
|
||||
@@ -96,6 +96,3 @@ sync_github_name = true
|
||||
|
||||
[people.tyrel]
|
||||
real_name = "Tyrel Haveman"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -1301,13 +1301,7 @@ impl<'h> Evaluator<'h> {
|
||||
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
|
||||
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
|
||||
let r = a.range;
|
||||
// A single row or column with one index picks along it. An area with several rows
|
||||
// and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third
|
||||
// argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ:
|
||||
// `INDEX({1,2;3,4},2)` is the row.
|
||||
if two_args && r.height() > 1 && r.width() > 1 {
|
||||
return err(CellError::Ref);
|
||||
}
|
||||
// A single row or column with one index picks along it.
|
||||
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
|
||||
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
|
||||
return err(CellError::Ref);
|
||||
|
||||
@@ -191,81 +191,6 @@ fn index_area_number_overflow() {
|
||||
}
|
||||
}
|
||||
|
||||
/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a
|
||||
/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value.
|
||||
#[test]
|
||||
fn index_with_one_index() {
|
||||
let mut t = T::new();
|
||||
for (i, v) in [10, 20, 30].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), &v.to_string());
|
||||
}
|
||||
let mut k = 1;
|
||||
for r in 5..=7 {
|
||||
for col in ["A", "B", "C"] {
|
||||
t.set(&format!("{col}{r}"), &k.to_string());
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
for (row, (formula, expected)) in [
|
||||
// Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value.
|
||||
("=INDEX({10;20;30},2)", 20.0),
|
||||
("=TYPE(INDEX({10;20;30},2))", 64.0),
|
||||
("=TYPE(INDEX(SEQUENCE(3),2))", 64.0),
|
||||
("=TYPE(INDEX({10;20;30},2,1))", 1.0),
|
||||
("=TYPE(INDEX({10,20,30},2))", 1.0),
|
||||
("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0),
|
||||
("=SUM(INDEX({10;20;30},2))", 20.0),
|
||||
// 1-D references with one index are single-cell references.
|
||||
("=TYPE(INDEX(A1:A3,2))", 1.0),
|
||||
("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0),
|
||||
("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0),
|
||||
// A 2-D array with one index is the row.
|
||||
("=SUM(INDEX({1,2;3,4},2))", 7.0),
|
||||
("=SUM(INDEX({1,2;3,4},0))", 10.0),
|
||||
// A 2-D reference with an empty (not omitted) column argument is the row.
|
||||
("=SUM(INDEX(A5:C7,2,))", 15.0),
|
||||
("=SUM(INDEX(A5:C7,2,,1))", 15.0),
|
||||
("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0),
|
||||
("=INDEX(A5:C7,2,2)", 5.0),
|
||||
// A 2-D reference with one index is #REF!, and the error carries through.
|
||||
("=TYPE(INDEX(A5:C7,2))", 16.0),
|
||||
("=ISREF(INDEX(A5:C7,2))", 0.0),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = format!("J{}", row * 4 + 1);
|
||||
t.set(&at, formula);
|
||||
let got = match t.get(&at) {
|
||||
Value::Bool(b) => f64::from(u8::from(b)),
|
||||
v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")),
|
||||
};
|
||||
assert_eq!(got, *expected, "{formula}");
|
||||
}
|
||||
for formula in [
|
||||
"=INDEX(A5:C7,2)",
|
||||
"=INDEX(A5:C7,0)",
|
||||
"=INDEX(A5:C7,1.9)",
|
||||
"=INDEX(A5:C7,)",
|
||||
"=INDEX(A5:B6,1)",
|
||||
"=INDEX(A5:C7,2)+1",
|
||||
"=SUM(INDEX(A5:C7,{1;2}))",
|
||||
"=ROWS(INDEX(INDEX(A5:C7,2),3))",
|
||||
"=SUM(A5:INDEX(A5:C7,2))",
|
||||
"=SUM(INDEX(A5:C7,2):C7)",
|
||||
] {
|
||||
t.set("L1", formula);
|
||||
assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}");
|
||||
}
|
||||
t.set("L1", "=INDEX(A5:C7,{1;2})");
|
||||
assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]);
|
||||
t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")");
|
||||
assert_eq!(t.get("L1"), Value::from("e"));
|
||||
// A bad position is still #VALUE!.
|
||||
t.set("L1", "=INDEX(A5:C7,\"x\")");
|
||||
assert_eq!(t.get("L1"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_cycles() {
|
||||
let mut t = T::new();
|
||||
@@ -404,149 +329,6 @@ fn date_text_coerces_in_the_workbook_date_system() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
|
||||
/// significant digits. Every expectation was observed in Excel 365 with these fixtures
|
||||
/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
|
||||
#[test]
|
||||
fn lookups_compare_numbers_exactly() {
|
||||
let mut t = T::new();
|
||||
for (at, input) in [
|
||||
("A1", "=0.1+0.2"),
|
||||
("A2", "0.3"),
|
||||
("A3", "=100+1E-14"),
|
||||
("A4", "100"),
|
||||
("A5", "=1E15+0.5"),
|
||||
("A6", "1E15"),
|
||||
// Ascending 0.1, 0.3+e, 0.5 and the same descending.
|
||||
("B1", "0.1"),
|
||||
("B2", "=0.1+0.2"),
|
||||
("B3", "0.5"),
|
||||
("C1", "0.5"),
|
||||
("C2", "=0.1+0.2"),
|
||||
("C3", "0.1"),
|
||||
// 0.3 next to 0.3+e, ascending and descending.
|
||||
("D1", "0.3"),
|
||||
("D2", "=0.1+0.2"),
|
||||
("D3", "0.7"),
|
||||
("E1", "0.7"),
|
||||
("E2", "=0.1+0.2"),
|
||||
("E3", "0.3"),
|
||||
("G1", "=0.1+0.2"),
|
||||
("H1", "x"),
|
||||
("G2", "row2"),
|
||||
// The bisection probes 0.3 first and must not run on into 0.3+e.
|
||||
("J1", "0.1"),
|
||||
("J2", "0.2"),
|
||||
("J3", "0.3"),
|
||||
("J4", "=0.1+0.2"),
|
||||
("J5", "0.4"),
|
||||
("K1", "a"),
|
||||
("K2", "b"),
|
||||
("K3", "c"),
|
||||
("K4", "d"),
|
||||
("K5", "e"),
|
||||
] {
|
||||
t.set(at, input);
|
||||
}
|
||||
let e = 0.1 + 0.2;
|
||||
let n = Value::Number;
|
||||
let s = |x: &str| Value::from(x);
|
||||
let na = Value::Error(CellError::NA);
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
// `=` and friends stay at 15 digits.
|
||||
("=A1=A2", Value::Bool(true)),
|
||||
("=A3=A4", Value::Bool(true)),
|
||||
("=A5=A6", Value::Bool(true)),
|
||||
("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
|
||||
// Exact match.
|
||||
("=MATCH(A1,A2:A2,0)", na.clone()),
|
||||
("=MATCH(A2,A1:A1,0)", na.clone()),
|
||||
("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
|
||||
("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
|
||||
("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
|
||||
("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
|
||||
("=XMATCH(A1,A2:A2)", na.clone()),
|
||||
("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
|
||||
("=MATCH(0.3,A1:A2,0)", n(2.0)),
|
||||
("=MATCH(A1,A1:A2,0)", n(1.0)),
|
||||
("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
|
||||
("=MATCH(100,A3:A3,0)", na.clone()),
|
||||
("=MATCH(1E15,A5:A5,0)", na.clone()),
|
||||
("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
|
||||
("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
|
||||
// Approximate match against a single entry.
|
||||
("=MATCH(0.3,A1:A1,1)", na.clone()),
|
||||
("=MATCH(0.3,A1:A1,-1)", n(1.0)),
|
||||
("=LOOKUP(0.3,A1:A1)", na.clone()),
|
||||
("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
|
||||
("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
|
||||
("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
|
||||
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
|
||||
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
|
||||
("=XMATCH(0.3,A1:A1,-1)", na.clone()),
|
||||
("=XMATCH(0.3,A1:A1,1)", n(1.0)),
|
||||
("=MATCH(100,A3:A3,1)", na.clone()),
|
||||
("=MATCH(A3,A4:A4,1)", n(1.0)),
|
||||
// Approximate match in sorted lists.
|
||||
("=MATCH(0.3,B1:B3,1)", n(1.0)),
|
||||
("=MATCH(A1,B1:B3,1)", n(2.0)),
|
||||
("=MATCH(0.3,C1:C3,-1)", n(2.0)),
|
||||
("=MATCH(0.3,D1:D3,1)", n(1.0)),
|
||||
("=MATCH(A1,D1:D3,1)", n(2.0)),
|
||||
("=MATCH(0.3,E1:E3,-1)", n(3.0)),
|
||||
("=MATCH(A1,E1:E3,-1)", n(2.0)),
|
||||
("=MATCH(0.3,J1:J5,1)", n(3.0)),
|
||||
("=MATCH(A1,J1:J5,1)", n(4.0)),
|
||||
("=MATCH(0.3,J1:J5,0)", n(3.0)),
|
||||
("=MATCH(A1,J1:J5,0)", n(4.0)),
|
||||
("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
|
||||
("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
|
||||
("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
|
||||
("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
|
||||
// XLOOKUP / XMATCH match and search modes.
|
||||
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
|
||||
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
|
||||
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
|
||||
("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
|
||||
("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
|
||||
("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
|
||||
("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
|
||||
("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
|
||||
("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
|
||||
("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
|
||||
("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
|
||||
("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
|
||||
("=XMATCH(0.3,J1:J5,1)", n(3.0)),
|
||||
("=XMATCH(A1,J1:J5,-1)", n(4.0)),
|
||||
("=XMATCH(0.3,B1:B3,1)", n(2.0)),
|
||||
("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
|
||||
("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
|
||||
("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
|
||||
// The criteria functions keep 15 digits.
|
||||
("=COUNTIF(A1:A1,0.3)", n(1.0)),
|
||||
("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
|
||||
("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
|
||||
("=COUNTIF(A2:A2,A1)", n(1.0)),
|
||||
("=COUNTIFS(A1:A1,0.3)", n(1.0)),
|
||||
("=SUMIF(A1:A1,0.3)", n(e)),
|
||||
("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
|
||||
("=AVERAGEIF(A1:A1,0.3)", n(e)),
|
||||
("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
|
||||
("=COUNTIF(A3:A3,\">100\")", n(0.0)),
|
||||
("=COUNTIF(J1:J5,0.3)", n(2.0)),
|
||||
("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
|
||||
("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
|
||||
];
|
||||
let mut wrong = Vec::new();
|
||||
for (formula, expected) in cases {
|
||||
let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
|
||||
if got != expected {
|
||||
wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_helpers_check_shapes_and_parameters() {
|
||||
let mut t = T::new();
|
||||
@@ -570,21 +352,6 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "'$(1,234)");
|
||||
t.set("B1", "=A1+0");
|
||||
t.set("C1", "=VALUE(A1)");
|
||||
t.set("D1", "=--\"$ (5)\"");
|
||||
t.set("E1", "=\"(5)%\"*100");
|
||||
t.set("F1", "=\"(-5)\"+0");
|
||||
t.set("G1", "$(1,234)");
|
||||
assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0));
|
||||
assert_eq!(t.get("F1"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("G1"), -1234.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
@@ -1746,91 +1513,6 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_toolpak_value_params_reject_multi_cell_references() {
|
||||
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
|
||||
// than one cell, however the reference is formed, and lift array constants and computed arrays.
|
||||
let mut t = T::new();
|
||||
for (a, v) in [
|
||||
("A1", "45000"),
|
||||
("A2", "45031"),
|
||||
("A3", "45061"),
|
||||
("B1", "1"),
|
||||
("B2", "2"),
|
||||
("B3", "3"),
|
||||
("C2", "7"),
|
||||
("F1", "1"),
|
||||
("F2", "2"),
|
||||
("F3", "11"),
|
||||
] {
|
||||
t.set(a, v);
|
||||
}
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Dates".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$A$1:$A$3".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let value = Value::Error(CellError::Value);
|
||||
for f in [
|
||||
"=EOMONTH(A1:A3,1)",
|
||||
"=EOMONTH(A1,B1:B3)",
|
||||
"=EDATE(A1:A3,1)",
|
||||
"=SUMPRODUCT(EOMONTH(A1:A3,0))",
|
||||
"=EOMONTH(INDEX(A1:A3,0,1),0)",
|
||||
"=EOMONTH(OFFSET(A1,0,0,3,1),0)",
|
||||
"=EOMONTH(IF(TRUE,A1:A3),0)",
|
||||
"=EOMONTH(CHOOSE(1,A1:A3),0)",
|
||||
"=LET(x,A1:A3,EOMONTH(x,0))",
|
||||
"=EOMONTH(Dates,0)",
|
||||
"=EOMONTH((A1,A2),0)",
|
||||
"=EOMONTH(Z:Z,0)",
|
||||
"=EOMONTH(A1:INDEX(A1:A3,3),0)",
|
||||
"=WORKDAY(A1:A3,1)",
|
||||
"=NETWORKDAYS(A1,A3:A4)",
|
||||
"=YEARFRAC(A1,A3,B1:B2)",
|
||||
"=WEEKNUM(A1,B1:B3)",
|
||||
"=COUPDAYS(A1:A3,46000,2,0)",
|
||||
"=DISC(A1,46000,98,100,B1:B3)",
|
||||
"=BIN2DEC(B1:B3)",
|
||||
"=ISEVEN(B1:B3)",
|
||||
] {
|
||||
t.set("H5", f);
|
||||
assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}");
|
||||
t.set("H5", "");
|
||||
}
|
||||
// One-cell references, intersections, @ and the range parameters are fine.
|
||||
for (f, want) in [
|
||||
("=EOMONTH(A1:A1,0)", 45016.0),
|
||||
("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0),
|
||||
("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0),
|
||||
("=EOMONTH(A1:A3 A2:C2,0)", 45046.0),
|
||||
("=EOMONTH(@A1:A3,0)", 45046.0),
|
||||
("=WORKDAY(A1,1,A1:A3)", 45001.0),
|
||||
("=XNPV(0,B1:B3,A1:A3)", 6.0),
|
||||
] {
|
||||
t.set("H2", f);
|
||||
assert_eq!(t.num("H2"), want, "{f}");
|
||||
t.set("H2", "");
|
||||
}
|
||||
// Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend.
|
||||
for (f, below, right) in [
|
||||
("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty),
|
||||
("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)),
|
||||
("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)),
|
||||
("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty),
|
||||
("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty),
|
||||
("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty),
|
||||
// The rejected reference adds no shape: a 2x1 array of #VALUE!.
|
||||
("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty),
|
||||
] {
|
||||
t.set("H5", f);
|
||||
assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}");
|
||||
t.set("H5", "");
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
|
||||
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
|
||||
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
|
||||
@@ -2099,40 +1781,3 @@ fn text_before_after_empty_instance_defaults_to_one() {
|
||||
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
|
||||
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loan_schedule_residue_keeps_excels_sign() {
|
||||
// A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest,
|
||||
// each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment
|
||||
// Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes
|
||||
// from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "4653300");
|
||||
t.set("B2", "1");
|
||||
t.set("B3", "0.141");
|
||||
let col = |i: u8| char::from(b'A' + i).to_string();
|
||||
t.set("C8", "=$B$1*($B$3/12)");
|
||||
t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8");
|
||||
t.set("D8", "=($B1-C7)*($B$3/12)");
|
||||
t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8");
|
||||
for i in 4..16 {
|
||||
let (c, p) = (col(i), col(i - 1));
|
||||
t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")"));
|
||||
t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")"));
|
||||
}
|
||||
t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")");
|
||||
// Excel's values (Value2) for the same synthetic inputs.
|
||||
// The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference
|
||||
// moves it by as much as its own size: only its sign is checked, and the 13th period must exist.
|
||||
let positive = |cell: &str| match t.get(cell) {
|
||||
Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"),
|
||||
other => panic!("{cell}: expected a small positive number, got {other:?}"),
|
||||
};
|
||||
positive("N4");
|
||||
positive("O8");
|
||||
match t.get("O7") {
|
||||
Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"),
|
||||
other => panic!("O7: expected a number, got {other:?}"),
|
||||
}
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
+20
-136
@@ -54,25 +54,23 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
/// fractions kept as text are not flagged, as in Excel.
|
||||
pub fn number_stored_as_text(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
// The same parentheses typed entry reads, accounting forms included: `$(5)`, `(5)%`.
|
||||
match parenthesized(t) {
|
||||
Some(inner) if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None,
|
||||
Some(inner) => parse_plain(&inner).map(|(n, _)| -n),
|
||||
match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n),
|
||||
None => parse_plain(t).map(|(n, _)| n),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
// Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`.
|
||||
if let Some(inner) = parenthesized(t) {
|
||||
// Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.
|
||||
if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {
|
||||
return None;
|
||||
}
|
||||
let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));
|
||||
return r.map(|(n, f)| (-n, f));
|
||||
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
|
||||
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
|
||||
let mut t = t;
|
||||
let mut neg = false;
|
||||
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
t = inner.trim();
|
||||
neg = !neg;
|
||||
}
|
||||
parse_unparenthesised(t, sys)
|
||||
let (n, f) = parse_unparenthesised(t, sys)?;
|
||||
Some((if neg { -n } else { n }, f))
|
||||
}
|
||||
|
||||
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
@@ -91,22 +89,6 @@ fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'stat
|
||||
None
|
||||
}
|
||||
|
||||
/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may
|
||||
/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as
|
||||
/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it.
|
||||
fn parenthesized(t: &str) -> Option<String> {
|
||||
let (currency, rest) = match t.strip_prefix('$') {
|
||||
Some(r) => ("$", r.trim_start()),
|
||||
None => ("", t),
|
||||
};
|
||||
let rest = rest.strip_prefix('(')?;
|
||||
let (body, percent) = match rest.strip_suffix(')') {
|
||||
Some(b) => (b, ""),
|
||||
None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"),
|
||||
};
|
||||
Some(format!("{currency}{}{percent}", body.trim()))
|
||||
}
|
||||
|
||||
/// Signs, currency, thousands separators, percent, exponent.
|
||||
fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
let mut s = t;
|
||||
@@ -131,8 +113,7 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
percent = true;
|
||||
s = r.trim_end();
|
||||
}
|
||||
// Excel reads `$5` and `5%` but not `$5%`.
|
||||
if s.is_empty() || currency && percent {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let has_sep = s.contains(',');
|
||||
@@ -441,107 +422,20 @@ mod tests {
|
||||
assert_eq!(p(".5").value, Value::Number(0.5));
|
||||
}
|
||||
|
||||
/// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree.
|
||||
#[test]
|
||||
fn accounting_parentheses() {
|
||||
let accepted: [(&str, f64, Option<&str>); 22] = [
|
||||
("$(5)", -5.0, Some("\"$\"#,##0")),
|
||||
("$ (5)", -5.0, Some("\"$\"#,##0")),
|
||||
("$( 5 )", -5.0, Some("\"$\"#,##0")),
|
||||
("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")),
|
||||
("$(1,234)", -1234.0, Some("\"$\"#,##0")),
|
||||
("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")),
|
||||
("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")),
|
||||
("$(.5)", -0.5, Some("\"$\"#,##0.00")),
|
||||
("$(0)", 0.0, Some("\"$\"#,##0")),
|
||||
(" $(5) ", -5.0, Some("\"$\"#,##0")),
|
||||
("($5)", -5.0, Some("\"$\"#,##0")),
|
||||
("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")),
|
||||
("(5)", -5.0, None),
|
||||
("( 5 )", -5.0, None),
|
||||
("(1,234.5)", -1234.5, Some("#,##0.00")),
|
||||
("(1e-3)", -0.001, Some("0.00E+00")),
|
||||
("(5)%", -0.05, Some("0%")),
|
||||
("( 5 ) %", -0.05, Some("0%")),
|
||||
("(5%)", -0.05, Some("0%")),
|
||||
("(5.)%", -0.05, Some("0.00%")),
|
||||
("(1,234)%", -12.34, Some("0%")),
|
||||
("(1 1/2)", -1.5, Some("# ?/?")),
|
||||
];
|
||||
for (s, n, f) in accepted {
|
||||
assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}");
|
||||
assert_eq!(parse_number_text(s), Some(n), "{s}");
|
||||
}
|
||||
for s in [
|
||||
"$5%",
|
||||
"$(5%)",
|
||||
"$(5)%",
|
||||
"($5%)",
|
||||
"($5)%",
|
||||
"$ ( 5 ) %",
|
||||
"(5)%%",
|
||||
"(5%)%",
|
||||
"((5))",
|
||||
"(-5)",
|
||||
"(+5)",
|
||||
"($-5)",
|
||||
"($ -5)",
|
||||
"($+5)",
|
||||
"(-$5)",
|
||||
"$(-5)",
|
||||
"$ (-5)",
|
||||
"$(+5)",
|
||||
"-(5)",
|
||||
"-$(5)",
|
||||
"+$(5)",
|
||||
"$+(5)",
|
||||
"$$(5)",
|
||||
"$ $(5)",
|
||||
"$((5))",
|
||||
"($(5))",
|
||||
"(-1 1/2)",
|
||||
"()",
|
||||
"$()",
|
||||
"(5)$",
|
||||
"$(5)$",
|
||||
"(5)(5)",
|
||||
"$(5)0",
|
||||
"(5)e3",
|
||||
"(5)-",
|
||||
"(5-)",
|
||||
"$(5-)",
|
||||
"$(5",
|
||||
"$5)",
|
||||
"(5",
|
||||
"$(1,23)",
|
||||
"$(,5)",
|
||||
"(5e)",
|
||||
"(1/2)",
|
||||
"$(1/2)",
|
||||
"(3/4/2026)",
|
||||
"$(3/4/2026)",
|
||||
"(10:30)",
|
||||
"$(10:30)",
|
||||
] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
assert_eq!(parse_number_text(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_parentheses_do_not_overflow() {
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack. Excel reads
|
||||
// one pair only, so nesting is text now, and it is rejected without recursing.
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
|
||||
let depth = 100_000;
|
||||
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
|
||||
assert_eq!(parse_number_text(&s), None);
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()));
|
||||
assert_eq!(parse_number_text(&s), Some(1.0));
|
||||
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
|
||||
assert_eq!(parse_number_text(&odd), None);
|
||||
assert_eq!(parse_number_text(&odd), Some(-1.0));
|
||||
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
|
||||
assert_eq!(parse_number_text(&unbalanced), None);
|
||||
// A single pair still reads.
|
||||
assert_eq!(p(&s).value, Value::Number(1.0));
|
||||
// Existing results are unchanged.
|
||||
assert_eq!(parse_number_text("(100)"), Some(-100.0));
|
||||
assert_eq!(parse_number_text("((100))"), Some(100.0));
|
||||
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
|
||||
assert_eq!(parse_number_text("()"), None);
|
||||
}
|
||||
@@ -595,21 +489,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn numbers_stored_as_text() {
|
||||
for (text, n) in [
|
||||
("123", 123.0),
|
||||
(" 42 ", 42.0),
|
||||
("-1,000.5", -1000.5),
|
||||
("$3", 3.0),
|
||||
("50%", 0.5),
|
||||
("1E3", 1000.0),
|
||||
("(100)", -100.0),
|
||||
("$(5)", -5.0),
|
||||
("(5)%", -0.05),
|
||||
] {
|
||||
for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] {
|
||||
assert_eq!(number_stored_as_text(text), Some(n), "{text}");
|
||||
}
|
||||
// Dates, times, fractions, words and blanks are not flagged.
|
||||
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))", "(-5)", "$(5)%", "(1/2)"] {
|
||||
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] {
|
||||
assert_eq!(number_stored_as_text(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,24 +652,6 @@ 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 };
|
||||
@@ -1572,33 +1554,6 @@ 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();
|
||||
|
||||
@@ -272,7 +272,6 @@ 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)
|
||||
})?;
|
||||
@@ -351,7 +350,6 @@ 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;
|
||||
}
|
||||
@@ -392,9 +390,7 @@ 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)));
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.set_cell(at, cell);
|
||||
super::data::grow_autofilter(sh, at);
|
||||
cx.sheet_mut(sheet)?.set_cell(at, cell);
|
||||
cx.touch(sheet, at);
|
||||
}
|
||||
}
|
||||
@@ -741,7 +737,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
// scripts get it only when they ask for it.
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust };
|
||||
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
|
||||
s.clipboard = Some(clip);
|
||||
let mut result = json!({"range": range.a1(), "text": text});
|
||||
if let Some(html) = html {
|
||||
@@ -1264,16 +1260,6 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
|
||||
Ok(())
|
||||
})?;
|
||||
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
|
||||
// formats (cell styles and merged areas, no conditional formats), column widths and notes
|
||||
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
|
||||
// except for column widths and notes, which write no cells at all.
|
||||
let passive =
|
||||
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
|
||||
if !same_doc && !passive {
|
||||
let d = s.doc_mut()?;
|
||||
d.trust = d.trust.receive(clip.trust);
|
||||
}
|
||||
if cut_move {
|
||||
s.clipboard = None;
|
||||
}
|
||||
|
||||
@@ -212,15 +212,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
trust_scripts
|
||||
),
|
||||
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
|
||||
cmd!(
|
||||
noundo "automate.stopAction",
|
||||
"Stop Action",
|
||||
[],
|
||||
None,
|
||||
"{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began",
|
||||
has_doc,
|
||||
stop_action
|
||||
),
|
||||
// Cell controls, themes, borders, styles
|
||||
cmd!(
|
||||
"insert.checkbox",
|
||||
@@ -809,7 +800,7 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust);
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
|
||||
copy.selection = d.selection.clone();
|
||||
copy.scripts_trusted = d.scripts_trusted;
|
||||
let i = s.add_document(copy);
|
||||
@@ -1034,11 +1025,6 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
||||
if id.starts_with("file.") || id.starts_with("link.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
// Scripts are scoped to their workbook: a step that switches the active window would
|
||||
// point every later step at another open document, so those are refused too.
|
||||
if id.starts_with("window.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
if params.get("path").is_some() {
|
||||
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
||||
}
|
||||
@@ -1101,13 +1087,6 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"ran": n}))
|
||||
}
|
||||
|
||||
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
|
||||
/// Redo work there again.
|
||||
fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let uid = s.doc()?.uid;
|
||||
Ok(json!({"stopped": s.abort_groups(uid)}))
|
||||
}
|
||||
|
||||
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(name) => {
|
||||
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
|
||||
let title = crate::sample::title(name);
|
||||
s.add_document(DocState::new(wb, None, title, crate::Trust::Local))
|
||||
s.add_document(DocState::new(wb, None, title))
|
||||
}
|
||||
None => s.new_workbook(),
|
||||
};
|
||||
@@ -83,8 +83,7 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
s.close_document(0);
|
||||
}
|
||||
// Someone else may have made the file: its active content asks before it runs.
|
||||
let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile);
|
||||
let mut doc = DocState::new(wb, path, title);
|
||||
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
|
||||
doc.content_kind = Some(crate::io::FileKind::Xlsx);
|
||||
}
|
||||
|
||||
@@ -233,23 +233,10 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
|
||||
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Formatting on a protected sheet keeps the cell's permission flags: a format call
|
||||
// that flips `locked` from the format permission would authorize content edits that
|
||||
// the password should still gate, so those flags may not change (Excel keeps that
|
||||
// checkbox disabled while the sheet is protected).
|
||||
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
|
||||
macro_rules! guard_flags {
|
||||
($old:expr, $new:expr) => {
|
||||
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
|
||||
return Err(EngineError::Other(FLAG_GUARD.into()));
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
|
||||
for r in &ranges {
|
||||
let full_rows = r.is_full_rows();
|
||||
@@ -264,7 +251,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
info.and_then(|i| i.style).unwrap_or_default()
|
||||
};
|
||||
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
|
||||
guard_flags!(cur, new);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
|
||||
map.entry(line).or_default().style = Some(new);
|
||||
@@ -274,7 +260,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
|
||||
for (c, st) in &cells {
|
||||
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
|
||||
guard_flags!(*st, new);
|
||||
cx.sheet_mut(sheet)?.set_style(*c, new);
|
||||
}
|
||||
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
|
||||
@@ -291,7 +276,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
|
||||
if new != st {
|
||||
guard_flags!(st, new);
|
||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -32,26 +32,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Define Name…",
|
||||
["Formulas", "Defined Names"],
|
||||
None,
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
|
||||
has_doc,
|
||||
define_name
|
||||
),
|
||||
cmd!(
|
||||
"formulas.deleteName",
|
||||
"Delete Name",
|
||||
[],
|
||||
None,
|
||||
"{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
|
||||
has_doc,
|
||||
delete_name
|
||||
),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
|
||||
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
|
||||
cmd!(
|
||||
"formulas.createFromSelection",
|
||||
"Create from Selection",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("Cmd+Shift+F3"),
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
|
||||
has_doc,
|
||||
create_from_selection
|
||||
),
|
||||
@@ -276,9 +268,6 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !valid_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() {
|
||||
refuse_stash_name(&s.doc()?.wb, p, n)?;
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let scope = match str_param(p, "scope") {
|
||||
None | Some("Workbook") => None,
|
||||
@@ -385,29 +374,8 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]):
|
||||
/// defining, renaming or deleting one would replace or remove part of an item (a stored macro,
|
||||
/// script or view) and leave the rest behind. `force: true` lets the call go ahead.
|
||||
/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope
|
||||
/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and
|
||||
/// the name commands may change it.
|
||||
fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool {
|
||||
// The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not.
|
||||
stash::holds_item(wb, name)
|
||||
}
|
||||
|
||||
fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> {
|
||||
if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
|
||||
refuse_stash_name(&s.doc()?.wb, p, &name)?;
|
||||
let scope = match str_param(p, "scope") {
|
||||
None => None,
|
||||
Some("Workbook") => Some(None),
|
||||
@@ -493,11 +461,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
|
||||
// reported.
|
||||
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
|
||||
// A label that names one of GridCraft's own hidden data names would replace part of an item
|
||||
// (see `refuse_stash_name`).
|
||||
let force = bool_param(p, "force").unwrap_or(false);
|
||||
let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n);
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n)));
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
|
||||
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
|
||||
let n = made.len();
|
||||
edit(s, |cx| {
|
||||
@@ -778,57 +742,6 @@ mod tests {
|
||||
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_commands_leave_gridcraft_data_alone() {
|
||||
// Defining, renaming or deleting a name of a stored item's shape would replace or remove
|
||||
// one of its chunks and leave the rest behind: refused unless `force` is true.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let text = "m".repeat(9_000);
|
||||
stash::set(wb, "macro_1", &text).unwrap();
|
||||
wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true });
|
||||
// A legacy item is read whether or not it is hidden, so a visible one is data too.
|
||||
wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false });
|
||||
let names = |s: &Session| s.doc().unwrap().wb.names.clone();
|
||||
let before = names(&s);
|
||||
assert!(before.len() >= 3, "the item takes two chunks");
|
||||
for params in [
|
||||
json!({"name": "_gc_macro_1_0", "refersTo": "=1"}),
|
||||
json!({"name": "_SC_Watch", "refersTo": "=1"}),
|
||||
json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}),
|
||||
json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}),
|
||||
json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}),
|
||||
] {
|
||||
let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string();
|
||||
assert!(err.contains("force"), "{params}: {err}");
|
||||
}
|
||||
for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] {
|
||||
let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string();
|
||||
assert!(err.contains("force"), "{name}: {err}");
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap();
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap();
|
||||
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"])));
|
||||
let after = names(&s);
|
||||
assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::<Vec<_>>(), before);
|
||||
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()));
|
||||
// A visible name of the shape is the person's own (the stash never reads it): the name
|
||||
// commands change and remove it without `force`, as Name Manager cannot pass one.
|
||||
let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false };
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone());
|
||||
s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap();
|
||||
s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own);
|
||||
s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap();
|
||||
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched");
|
||||
// Ordinary names and forced calls go ahead.
|
||||
s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap();
|
||||
s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap();
|
||||
assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_defined_names_count_characters_not_bytes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -7,11 +7,11 @@ use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect),
|
||||
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
|
||||
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history),
|
||||
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
|
||||
]
|
||||
}
|
||||
@@ -63,7 +63,6 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"title": d.display_title(),
|
||||
"path": d.path,
|
||||
"dirty": d.is_dirty(),
|
||||
"trust": d.trust,
|
||||
"activeSheet": wb.active_sheet,
|
||||
"sheets": sheets,
|
||||
"selection": d.selection.a1(),
|
||||
@@ -149,11 +148,9 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn history(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
Ok(json!({
|
||||
"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
|
||||
"redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
|
||||
"grouping": s.in_group(d.uid),
|
||||
}))
|
||||
Ok(
|
||||
json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>()}),
|
||||
)
|
||||
}
|
||||
|
||||
fn list_commands(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -19,7 +19,6 @@ pub mod sheet;
|
||||
pub mod slicer;
|
||||
pub mod solver;
|
||||
pub mod spelling;
|
||||
pub mod stash;
|
||||
pub mod view;
|
||||
pub mod whatif;
|
||||
|
||||
@@ -97,19 +96,12 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
not_grouping(s)?;
|
||||
if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
not_grouping(s)?;
|
||||
if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
|
||||
}
|
||||
/// Undo and Redo wait while an undo group is open on the active document (an action that spans
|
||||
/// several calls is still running).
|
||||
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
|
||||
}
|
||||
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
|
||||
@@ -187,23 +179,6 @@ pub fn register_test_command(id: &'static str, run: Run) {
|
||||
test_commands().push(spec);
|
||||
}
|
||||
|
||||
/// Test-only: an undoable command, to exercise how `execute` records its step.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||||
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
|
||||
id,
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
journal: false,
|
||||
undoable: true,
|
||||
}));
|
||||
test_commands().push(spec);
|
||||
}
|
||||
|
||||
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
|
||||
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
|
||||
match TEST_COMMANDS.lock() {
|
||||
|
||||
+10
-227
@@ -179,15 +179,6 @@ 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",
|
||||
@@ -663,7 +654,16 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, anchor) = match target {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let at = insert_sheet_before_active(cx);
|
||||
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;
|
||||
}
|
||||
}
|
||||
new_sheet_at = Some(at);
|
||||
(at, CellRef::new(2, 0))
|
||||
}
|
||||
@@ -726,104 +726,6 @@ 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") {
|
||||
@@ -2334,7 +2236,6 @@ mod tests {
|
||||
"data.refreshAll",
|
||||
"pivot.changeSource",
|
||||
"pivot.delete",
|
||||
"pivot.showDetails",
|
||||
];
|
||||
let hostile = [
|
||||
json!({}),
|
||||
@@ -2462,122 +2363,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,10 +401,8 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
|
||||
}
|
||||
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
|
||||
// sheet is active: both carry the command's own `sheet` parameter on.
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
|
||||
|
||||
let moved_from = if before <= from { from + count } else { from };
|
||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
@@ -588,15 +586,11 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let revision = d.revision;
|
||||
let before = (d.wb.clone(), d.selection.clone());
|
||||
// Inside an undo group the nested steps add no undo entries of their own.
|
||||
let nested_entries = !s.in_group(d.uid);
|
||||
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
|
||||
let d = s.doc_mut()?;
|
||||
// The nested steps collapse into this command's single undo step.
|
||||
if nested_entries {
|
||||
for _ in 0..d.revision.saturating_sub(revision) {
|
||||
d.undo.pop();
|
||||
}
|
||||
for _ in 0..d.revision.saturating_sub(revision) {
|
||||
d.undo.pop();
|
||||
}
|
||||
match result {
|
||||
Ok((final_range, out)) => {
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx
|
||||
//! file.
|
||||
//!
|
||||
//! An item `key` is stored as names `_gc_<key>_0`, `_gc_<key>_1`, …, each a formula of string
|
||||
//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a
|
||||
//! formula to 8,192, so a long value is split over as many names as it needs and the file stays
|
||||
//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep
|
||||
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
|
||||
//! format later, and a write of the same key replaces it.
|
||||
//!
|
||||
//! XML can't carry every character (controls other than tab, line feed and carriage return, and
|
||||
//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the
|
||||
//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits
|
||||
//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode
|
||||
//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on
|
||||
//! ordinary text. Legacy `_sc_` items are read as they are.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::{DefinedName, Workbook};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const PREFIX: &str = "_gc_";
|
||||
const LEGACY_PREFIX: &str = "_sc_";
|
||||
/// Characters (UTF-16 units, as Excel counts them) in one string literal.
|
||||
const LITERAL_UNITS: usize = 255;
|
||||
/// Characters in one name's formula.
|
||||
const FORMULA_UNITS: usize = 8_192;
|
||||
/// The longest key: `_gc_<key>_<k>` stays well inside Excel's 255-character names.
|
||||
const MAX_KEY: usize = 200;
|
||||
/// The longest item: 4,194,304 characters.
|
||||
pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024;
|
||||
/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character
|
||||
/// takes seven (the three-byte marker and four digits).
|
||||
const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7;
|
||||
/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter.
|
||||
pub const ESCAPE: char = '\u{FDD0}';
|
||||
/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A
|
||||
/// file can carry any number of hidden names; the rest are not listed.
|
||||
pub const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its
|
||||
/// legacy `_sc_` name.
|
||||
#[derive(Default)]
|
||||
struct Parts<'a> {
|
||||
chunks: BTreeMap<u64, &'a str>,
|
||||
legacy: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Parts<'a> {
|
||||
fn add(&mut self, chunk: Option<u64>, formula: &'a str) {
|
||||
match chunk {
|
||||
Some(i) => {
|
||||
self.chunks.entry(i).or_insert(formula);
|
||||
}
|
||||
None => self.legacy = self.legacy.or(Some(formula)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the names make an item: a later chunk on its own does not.
|
||||
fn is_item(&self) -> bool {
|
||||
self.chunks.contains_key(&0) || self.legacy.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Names [`group`] has looked at on this thread, so a test can bound the work of a listing.
|
||||
static NAME_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names
|
||||
/// that hold them, in one pass over the names.
|
||||
fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap<String, Parts<'a>> {
|
||||
let prefix = prefix.to_ascii_lowercase();
|
||||
let mut items: BTreeMap<String, Parts<'a>> = BTreeMap::new();
|
||||
for n in wb.names.iter() {
|
||||
#[cfg(test)]
|
||||
NAME_VISITS.with(|v| v.set(v.get().saturating_add(1)));
|
||||
let Some((k, chunk)) = item_of(n) else { continue };
|
||||
let k = k.to_ascii_lowercase();
|
||||
if k.starts_with(&prefix) {
|
||||
items.entry(k).or_default().add(chunk, &n.formula);
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// The item text stored under `key`, or `None` when there is none (or it is over the size limit).
|
||||
pub fn get(wb: &Workbook, key: &str) -> Option<String> {
|
||||
let mut parts = Parts::default();
|
||||
for n in wb.names.iter() {
|
||||
if let Some((k, chunk)) = item_of(n)
|
||||
&& k.eq_ignore_ascii_case(key)
|
||||
{
|
||||
parts.add(chunk, &n.formula);
|
||||
}
|
||||
}
|
||||
assemble(&parts)
|
||||
}
|
||||
|
||||
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
|
||||
/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit.
|
||||
pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap<String, String> {
|
||||
group(wb, prefix)
|
||||
.into_iter()
|
||||
.filter(|(_, parts)| parts.is_item())
|
||||
.take(MAX_ITEMS)
|
||||
.filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text of the item held by `parts`, as [`get`] reads it.
|
||||
fn assemble(parts: &Parts) -> Option<String> {
|
||||
let chunks = &parts.chunks;
|
||||
let mut out = String::new();
|
||||
if !chunks.contains_key(&0) {
|
||||
out = string_literals(parts.legacy?);
|
||||
} else {
|
||||
// Chunks run 0, 1, 2, …; anything after a gap isn't part of the item.
|
||||
for (want, (i, f)) in chunks.iter().enumerate() {
|
||||
if *i != want as u64 {
|
||||
break;
|
||||
}
|
||||
out.push_str(&string_literals(f));
|
||||
if out.len() > MAX_STORED_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
out = unescape(&out);
|
||||
}
|
||||
(out.chars().count() <= MAX_ITEM_CHARS).then_some(out)
|
||||
}
|
||||
|
||||
/// Whether `c` is stored escaped: XML can't carry it, or it is the marker.
|
||||
fn needs_escape(c: char) -> bool {
|
||||
c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four
|
||||
/// hex digits naming a character (a hand-made name) stays as it is.
|
||||
fn unescape(s: &str) -> String {
|
||||
if !s.contains(ESCAPE) {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(at) = rest.find(ESCAPE) {
|
||||
out.push_str(rest.get(..at).unwrap_or(""));
|
||||
let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or("");
|
||||
let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
match code.and_then(char::from_u32) {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
rest = after.get(4..).unwrap_or("");
|
||||
}
|
||||
None => {
|
||||
out.push(ESCAPE);
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
|
||||
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
|
||||
check_key(key)?;
|
||||
if text.chars().count() > MAX_ITEM_CHARS {
|
||||
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
|
||||
}
|
||||
let formulas = chunk_formulas(text);
|
||||
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
|
||||
// it with the same name would be the one an xlsx file drops. Refuse before changing anything,
|
||||
// in one pass over the names (a file can carry many thousands).
|
||||
let chunks = formulas.len() as u64;
|
||||
let taken = |n: &&DefinedName| {
|
||||
n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks))
|
||||
};
|
||||
if let Some(n) = wb.names.iter().find(taken) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
|
||||
n.name
|
||||
)));
|
||||
}
|
||||
remove(wb, key);
|
||||
for (i, formula) in formulas.into_iter().enumerate() {
|
||||
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the item stored under `key`.
|
||||
pub fn remove(wb: &mut Workbook, key: &str) {
|
||||
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key)));
|
||||
}
|
||||
|
||||
/// The item under `key` as JSON (`None` when missing or not JSON).
|
||||
pub fn get_json(wb: &Workbook, key: &str) -> Option<Json> {
|
||||
serde_json::from_str(&get(wb, key)?).ok()
|
||||
}
|
||||
|
||||
pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
|
||||
set(wb, key, &v.to_string())
|
||||
}
|
||||
|
||||
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
|
||||
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
|
||||
/// where a [`get`] per key would take one each.
|
||||
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
|
||||
group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect()
|
||||
}
|
||||
|
||||
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
|
||||
/// `_gc_<key>_<k>` name, or a workbook-scope legacy `_sc_<key>` name, which is read whether or not
|
||||
/// it is hidden. A visible `_gc_` name is the person's own.
|
||||
pub fn holds_item(wb: &Workbook, name: &str) -> bool {
|
||||
wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some())
|
||||
}
|
||||
|
||||
/// The item a hidden name belongs to, lower-cased: `_gc_<key>_<k>` and `_sc_<key>` give `<key>`.
|
||||
pub fn item_key(name: &str) -> Option<String> {
|
||||
parse_name(name).map(|(k, _)| k.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The item a name holds a part of: a hidden workbook-scope `_gc_<key>_<k>` name, or a legacy
|
||||
/// `_sc_<key>` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is
|
||||
/// neither read, nor listed, nor removed on write.
|
||||
fn item_of(n: &DefinedName) -> Option<(&str, Option<u64>)> {
|
||||
if n.scope.is_some() {
|
||||
return None;
|
||||
}
|
||||
parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden)
|
||||
}
|
||||
|
||||
/// `_gc_<key>_<k>` → (`key`, `Some(k)`); `_sc_<key>` → (`key`, `None`).
|
||||
fn parse_name(name: &str) -> Option<(&str, Option<u64>)> {
|
||||
if let Some(rest) = strip_prefix_ci(name, PREFIX) {
|
||||
let (key, k) = rest.rsplit_once('_')?;
|
||||
// Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not
|
||||
// chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`.
|
||||
let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0'));
|
||||
if key.is_empty() || !canonical {
|
||||
return None;
|
||||
}
|
||||
return k.parse().ok().map(|i| (key, Some(i)));
|
||||
}
|
||||
strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None))
|
||||
}
|
||||
|
||||
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let head = s.get(..prefix.len())?;
|
||||
head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten()
|
||||
}
|
||||
|
||||
fn check_key(key: &str) -> Result<()> {
|
||||
let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.');
|
||||
if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) }
|
||||
}
|
||||
|
||||
/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula
|
||||
/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]).
|
||||
/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back.
|
||||
pub fn chunk_formulas(text: &str) -> Vec<String> {
|
||||
let mut formulas = Vec::new();
|
||||
let (mut formula, mut units) = (String::new(), 0usize);
|
||||
let (mut literal, mut lit_units) = (String::from("\""), 0usize);
|
||||
let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| {
|
||||
literal.push('"');
|
||||
let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty());
|
||||
if *units + cost > FORMULA_UNITS && !formula.is_empty() {
|
||||
formulas.push(std::mem::take(formula));
|
||||
*units = 0;
|
||||
}
|
||||
if !formula.is_empty() {
|
||||
formula.push('&');
|
||||
*units += 1;
|
||||
}
|
||||
*units += literal.encode_utf16().count();
|
||||
formula.push_str(literal);
|
||||
*literal = String::from("\"");
|
||||
*lit_units = 0;
|
||||
};
|
||||
for ch in text.chars() {
|
||||
// An escaped character is the marker and four digits, kept in one literal.
|
||||
let escaped = needs_escape(ch);
|
||||
let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() };
|
||||
if lit_units + cost > LITERAL_UNITS {
|
||||
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
|
||||
}
|
||||
lit_units += cost;
|
||||
if escaped {
|
||||
literal.push(ESCAPE);
|
||||
let _ = write!(literal, "{:04X}", u32::from(ch));
|
||||
} else {
|
||||
if ch == '"' {
|
||||
literal.push('"');
|
||||
}
|
||||
literal.push(ch);
|
||||
}
|
||||
}
|
||||
if lit_units > 0 {
|
||||
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
|
||||
}
|
||||
if !formula.is_empty() {
|
||||
formulas.push(formula);
|
||||
}
|
||||
formulas
|
||||
}
|
||||
|
||||
/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored).
|
||||
pub fn string_literals(f: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut it = f.chars().peekable();
|
||||
let mut in_str = false;
|
||||
while let Some(c) = it.next() {
|
||||
if !in_str {
|
||||
if c == '"' {
|
||||
in_str = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
if it.peek() == Some(&'"') {
|
||||
it.next();
|
||||
out.push('"');
|
||||
} else {
|
||||
in_str = false;
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn name(n: &str, formula: &str) -> DefinedName {
|
||||
DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true }
|
||||
}
|
||||
|
||||
fn excel_limits_hold(wb: &Workbook) {
|
||||
for n in &wb.names {
|
||||
assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len());
|
||||
// Every literal (quotes undoubled) is at most 255 characters.
|
||||
for lit in n.formula.split("\"&\"") {
|
||||
let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\"");
|
||||
assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_values_split_over_names_and_come_back() {
|
||||
let mut wb = Workbook::new();
|
||||
// Quotes double when written; characters outside the BMP count twice.
|
||||
let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect();
|
||||
set(&mut wb, "big", &text).unwrap();
|
||||
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
|
||||
assert!(names.len() > 5, "{names:?}");
|
||||
assert_eq!(names[0], "_gc_big_0");
|
||||
assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none()));
|
||||
excel_limits_hold(&wb);
|
||||
assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive");
|
||||
// Shorter again: the extra chunks go.
|
||||
set(&mut wb, "big", "short").unwrap();
|
||||
assert_eq!(wb.names.len(), 1);
|
||||
assert_eq!(wb.names[0].formula, "\"short\"");
|
||||
set(&mut wb, "big", "").unwrap();
|
||||
assert!(wb.names.is_empty());
|
||||
assert_eq!(get(&wb, "big"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_and_literals_respect_the_boundaries() {
|
||||
for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] {
|
||||
let text = "q".repeat(len);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text, "{len}");
|
||||
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}");
|
||||
}
|
||||
// All quotes: each literal doubles in the formula.
|
||||
let text = "\"".repeat(9_000);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text);
|
||||
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_single_name_still_reads_and_is_replaced_on_write() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\""));
|
||||
wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\""));
|
||||
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]})));
|
||||
assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2])));
|
||||
set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap();
|
||||
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]);
|
||||
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_names_read_safely() {
|
||||
let mut wb = Workbook::new();
|
||||
// A gap: chunk 2 without chunk 1.
|
||||
wb.names.push(name("_gc_gap_0", "\"a\""));
|
||||
wb.names.push(name("_gc_gap_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "gap").as_deref(), Some("a"));
|
||||
// Only a later chunk: no item, not listed, and the legacy name is still read.
|
||||
wb.names.push(name("_gc_orphan_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "orphan"), None);
|
||||
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
|
||||
wb.names.push(name("_sc_orphan", "\"old\""));
|
||||
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
|
||||
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
|
||||
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
|
||||
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
|
||||
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
|
||||
wb.names.push(name("_gc_mix_0", "\"ignored\""));
|
||||
wb.names.push(name("_gc_mix_2", "\"c"));
|
||||
assert_eq!(get(&wb, "mix").as_deref(), Some("abc"));
|
||||
// Names that only look like chunks.
|
||||
for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] {
|
||||
wb.names.push(name(odd, "\"zzz\""));
|
||||
}
|
||||
assert_eq!(get(&wb, "x"), None);
|
||||
assert_eq!(get(&wb, ""), None);
|
||||
// Not JSON, or JSON nested deeper than the parser allows.
|
||||
wb.names.push(name("_gc_notjson_0", "\"{\""));
|
||||
assert_eq!(get_json(&wb, "notjson"), None);
|
||||
let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000));
|
||||
wb.names.push(name("_gc_deep_0", &deep));
|
||||
assert_eq!(get_json(&wb, "deep"), None);
|
||||
// Sheet-scoped names are someone else's.
|
||||
wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") });
|
||||
assert_eq!(get(&wb, "scoped"), None);
|
||||
// Bad keys are refused.
|
||||
for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] {
|
||||
assert!(set(&mut wb, bad, "v").is_err(), "{bad}");
|
||||
}
|
||||
assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_items_are_not_read() {
|
||||
let mut wb = Workbook::new();
|
||||
let chunk = format!("\"{}\"", "x".repeat(250));
|
||||
let big = vec![chunk.as_str(); 32].join("&");
|
||||
for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) {
|
||||
wb.names.push(name(&format!("_gc_huge_{i}"), &big));
|
||||
}
|
||||
assert_eq!(get(&wb, "huge"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visible_name_of_the_item_shape_is_not_an_item() {
|
||||
// Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and
|
||||
// writing or removing the item leaves it alone.
|
||||
let mut wb = Workbook::new();
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
|
||||
// keeps only one of them: refused, with the names as they were.
|
||||
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
|
||||
assert!(err.contains("_gc_watch_0"), "{err}");
|
||||
assert_eq!(wb.names, vec![own.clone()]);
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
set(&mut wb, "other", "theirs").unwrap();
|
||||
assert_eq!(get(&wb, "other").as_deref(), Some("theirs"));
|
||||
remove(&mut wb, "watch");
|
||||
remove(&mut wb, "other");
|
||||
assert_eq!(wb.names, vec![own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_number_with_leading_zeros_is_another_name() {
|
||||
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
|
||||
// visible one is not in the way of writing `_gc_watch_0`.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_watch_00", "\"theirs\""));
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
set(&mut wb, "watch", "ours").unwrap();
|
||||
assert_eq!(get(&wb, "watch").as_deref(), Some("ours"));
|
||||
assert_eq!(wb.names.len(), 3, "both other names stay");
|
||||
remove(&mut wb, "watch");
|
||||
assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_list_items_by_prefix() {
|
||||
let mut wb = Workbook::new();
|
||||
set(&mut wb, "macro_2", "b").unwrap();
|
||||
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
|
||||
set(&mut wb, "scripts", "{}").unwrap();
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
|
||||
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
|
||||
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
|
||||
assert_eq!(item_key("Total"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_many_items_takes_one_pass_and_is_capped() {
|
||||
// 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each
|
||||
// key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS.
|
||||
let mut wb = Workbook::new();
|
||||
let total = 20_000;
|
||||
for i in 0..total {
|
||||
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
|
||||
}
|
||||
set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap();
|
||||
wb.names.push(name("_sc_macro_legacy", "\"old\""));
|
||||
wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\""));
|
||||
wb.names.push(name("_gc_other_0", "\"not a macro\""));
|
||||
NAME_VISITS.with(|v| v.set(0));
|
||||
let all = items(&wb, "MACRO_");
|
||||
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
|
||||
assert_eq!(all.len(), MAX_ITEMS);
|
||||
let first: Vec<&String> = all.keys().take(2).collect();
|
||||
assert_eq!(first, ["macro_00000", "macro_00001"]);
|
||||
assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999"));
|
||||
assert!(!all.contains_key("macro_10000"), "past the cap");
|
||||
NAME_VISITS.with(|v| v.set(0));
|
||||
let listed = keys(&wb, "macro_");
|
||||
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len());
|
||||
assert_eq!(listed, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
|
||||
|
||||
// Under the cap every item is there, read as `get` reads it.
|
||||
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
|
||||
let few = items(&wb, "macro_");
|
||||
let want: BTreeMap<String, String> =
|
||||
[("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect();
|
||||
assert_eq!(few, want);
|
||||
for (k, text) in &few {
|
||||
assert_eq!(get(&wb, k).as_ref(), Some(text));
|
||||
}
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]);
|
||||
assert_eq!(items(&wb, "").len(), 3, "the other item too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_round_trip_through_xlsx() {
|
||||
let mut wb = Workbook::new();
|
||||
let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect();
|
||||
set(&mut wb, "blob", &text).unwrap();
|
||||
wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\""));
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"])));
|
||||
|
||||
// Characters XML can't carry, and the escape marker itself, come back too, in text and
|
||||
// in JSON (which leaves U+FFFE and U+FFFF unescaped).
|
||||
let mut wb = Workbook::new();
|
||||
let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect();
|
||||
let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect();
|
||||
set(&mut wb, "odd", &text).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"});
|
||||
set_json(&mut wb, "json", &json).unwrap();
|
||||
// All escapes: each literal holds whole escapes, within the limits.
|
||||
let controls = "\u{1}".repeat(9_000);
|
||||
set(&mut wb, "controls", &controls).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get_json(&back, "json"), Some(json));
|
||||
assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() {
|
||||
// Excel's limits hold however many characters are escaped, and the longest item of
|
||||
// characters that all need escaping still reads.
|
||||
for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] {
|
||||
let text = "\u{2}".repeat(len);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::<String>()), text, "{len}");
|
||||
assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}");
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let longest = "\u{0}".repeat(MAX_ITEM_CHARS);
|
||||
set(&mut wb, "longest", &longest).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS));
|
||||
// A marker in a hand-made name that is not an escape stays as it is.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\""));
|
||||
assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}"));
|
||||
}
|
||||
}
|
||||
+9
-439
@@ -19,7 +19,6 @@ pub mod outline;
|
||||
pub mod paper;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod record;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
@@ -67,51 +66,6 @@ pub struct HistoryEntry {
|
||||
pub retained_bytes: usize,
|
||||
}
|
||||
|
||||
/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
|
||||
/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[must_use = "an undo group stays open until it is ended or rolled back"]
|
||||
pub struct GroupToken {
|
||||
id: u64,
|
||||
doc: u64,
|
||||
}
|
||||
|
||||
impl GroupToken {
|
||||
/// The uid of the document the group belongs to.
|
||||
pub fn doc(&self) -> u64 {
|
||||
self.doc
|
||||
}
|
||||
}
|
||||
|
||||
/// An open undo group: what runs on `doc` until it ends becomes one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
struct OpenGroup {
|
||||
id: u64,
|
||||
doc: u64,
|
||||
label: String,
|
||||
/// Undo depth when the group began; the group's own steps are cut back to it.
|
||||
start: usize,
|
||||
wb: Arc<Workbook>,
|
||||
selection: Selection,
|
||||
/// The redo stack when the group began (a rollback puts it back).
|
||||
redo: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
|
||||
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
|
||||
/// command that ended the group tells its own later changes from the group's step by it, and
|
||||
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
|
||||
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
|
||||
/// workbook alive between commands.
|
||||
#[derive(Debug)]
|
||||
struct GroupStep {
|
||||
doc: u64,
|
||||
after: Arc<Workbook>,
|
||||
selection: Selection,
|
||||
len: usize,
|
||||
stepped: bool,
|
||||
}
|
||||
|
||||
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Clipboard {
|
||||
@@ -130,34 +84,6 @@ pub struct Clipboard {
|
||||
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
|
||||
/// together into one block at `range`. Empty for a single-range copy.
|
||||
pub areas: Vec<RangeRef>,
|
||||
/// Trust of the source document: pasting content from a file someone else made carries it.
|
||||
pub trust: Trust,
|
||||
}
|
||||
|
||||
/// Whether a document's active content (macros) may run without asking the person first. There
|
||||
/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new
|
||||
/// way of building one from bytes can't come out trusted by omission.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Trust {
|
||||
/// Made in this session: a new workbook or a sample.
|
||||
Local,
|
||||
/// Opened from a file, or holding content pasted from one: active content asks before it
|
||||
/// runs.
|
||||
FromFile,
|
||||
}
|
||||
|
||||
impl Trust {
|
||||
/// Whether active content may run without asking.
|
||||
pub fn allows_code(self) -> bool {
|
||||
self == Trust::Local
|
||||
}
|
||||
|
||||
/// The trust of a document after it receives content from a document with trust `source`:
|
||||
/// content from an untrusted file leaves the receiver untrusted.
|
||||
pub fn receive(self, source: Trust) -> Trust {
|
||||
if source == Trust::FromFile { Trust::FromFile } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// In-cell edit mode as shown in the status bar.
|
||||
@@ -194,11 +120,6 @@ pub struct DocState {
|
||||
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
|
||||
/// trusted, since any script it gains later was written here.
|
||||
pub scripts_trusted: bool,
|
||||
/// Where the workbook's content came from: whether its active content may run without
|
||||
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
|
||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||
/// changed by a trust decision.
|
||||
pub trust: Trust,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -239,31 +160,17 @@ fn push_history_with_budget(
|
||||
selection: Selection,
|
||||
byte_budget: usize,
|
||||
) {
|
||||
push_entry(history, label, wb, next, selection);
|
||||
trim_to_budget(history, byte_budget);
|
||||
}
|
||||
|
||||
/// Adds a step without applying the limits: inside a command they wait for the outermost
|
||||
/// command to collapse its steps (see [`Session::trim_history`]).
|
||||
fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
|
||||
let retained_bytes = history_retained_bytes(&wb, next);
|
||||
history.push(HistoryEntry { label, wb, selection, retained_bytes });
|
||||
}
|
||||
|
||||
/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
|
||||
/// (even if that one edit exceeds the budget).
|
||||
fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
|
||||
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
|
||||
// Always preserve the latest action, even if that one edit exceeds the budget.
|
||||
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
|
||||
total = total.saturating_sub(history.remove(0).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything
|
||||
/// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook
|
||||
/// made in this session.
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
@@ -284,7 +191,6 @@ impl DocState {
|
||||
unlocked_ranges: vec![],
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
trust,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -401,13 +307,8 @@ pub struct Session {
|
||||
pub last_find: Option<Json>,
|
||||
/// View › Show › Ruler is off (rulers in Page Layout view; an application option).
|
||||
pub hide_ruler: bool,
|
||||
/// Journal position where Automate › Record Actions started.
|
||||
/// Journal position where action recording started.
|
||||
pub recording: Option<usize>,
|
||||
/// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level
|
||||
/// commands with the place they ran in, kept apart from the journal.
|
||||
pub recorder: Option<record::Recorder>,
|
||||
/// How many `execute` calls are running (0 between top-level commands).
|
||||
depth: u32,
|
||||
/// Windows hidden with View › Hide.
|
||||
pub hidden_windows: Vec<usize>,
|
||||
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
|
||||
@@ -419,17 +320,6 @@ pub struct Session {
|
||||
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
|
||||
/// (like Excel's comment indicator option), so it's not saved in the file.
|
||||
pub show_all_notes: bool,
|
||||
/// Open undo groups, innermost last.
|
||||
groups: Vec<OpenGroup>,
|
||||
next_group: u64,
|
||||
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
|
||||
group_epoch: u64,
|
||||
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
|
||||
group_step: Option<GroupStep>,
|
||||
/// For each running command, innermost last: its document and that document's undo length
|
||||
/// when it began. Steps before that length are not touched while the command runs (see
|
||||
/// [`Session::bound_tail`]).
|
||||
marks: Vec<(u64, usize)>,
|
||||
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
|
||||
/// [`cmd::format::approx_text_width`].
|
||||
pub text_width: Option<TextWidth>,
|
||||
@@ -469,8 +359,7 @@ impl Session {
|
||||
}
|
||||
pub fn close_document(&mut self, i: usize) {
|
||||
if i < self.docs.len() {
|
||||
let d = self.docs.remove(i);
|
||||
self.groups.retain(|g| g.doc != d.uid);
|
||||
self.docs.remove(i);
|
||||
if self.active >= self.docs.len() {
|
||||
self.active = self.docs.len().saturating_sub(1);
|
||||
}
|
||||
@@ -484,7 +373,7 @@ impl Session {
|
||||
let name = wb.next_sheet_name();
|
||||
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
|
||||
}
|
||||
self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local))
|
||||
self.add_document(DocState::new(wb, None, format!("Book{n}")))
|
||||
}
|
||||
|
||||
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
|
||||
@@ -523,8 +412,7 @@ impl Session {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
|
||||
/// Runs a command by id. Undoable commands record a history step when the workbook changed,
|
||||
/// unless an undo group is open on the document (the group then makes the step).
|
||||
/// Runs a command by id. Undoable commands record a history step when the workbook changed.
|
||||
/// Panics inside commands are caught and reported; the workbook is restored.
|
||||
pub fn execute(&mut self, id: &str, params: Json) -> Result<Json> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
@@ -533,352 +421,36 @@ impl Session {
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
|
||||
let params = if params.is_null() { Json::Object(Default::default()) } else { params };
|
||||
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
|
||||
// Whether a group was open on the document before the command, and how long its history was.
|
||||
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
|
||||
// A command that panics leaves the history as it found it: the steps commands nested in it
|
||||
// made or undid, and the redo history they cleared, come back. A length is not enough,
|
||||
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
|
||||
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
|
||||
// copy; front ends run queries all the time.
|
||||
let query = !spec.journal && !spec.undoable;
|
||||
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
|
||||
#[cfg(test)]
|
||||
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
|
||||
self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))
|
||||
});
|
||||
// The groups open on the document before the command (usually none), for the same reason.
|
||||
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
|
||||
Some((uid, ..)) => {
|
||||
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
// The recorder keeps top-level calls with the place they ran in.
|
||||
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
|
||||
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
|
||||
None => (0, 0, String::new(), Selection::default()),
|
||||
});
|
||||
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
|
||||
let requests_before = self.ui_requests.len();
|
||||
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
|
||||
self.marks.extend(marked);
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms)));
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
if marked.is_some() {
|
||||
self.marks.pop();
|
||||
}
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
// Restore the workbook and its history as they were, and drop the undo groups the
|
||||
// command opened.
|
||||
self.groups.retain(|g| g.id < first_group);
|
||||
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
|
||||
// Restore the workbook as it was.
|
||||
if let Some((uid, wb, sel)) = &before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
|
||||
{
|
||||
d.wb = wb.clone();
|
||||
d.selection = sel.clone();
|
||||
if let Some((undo, redo)) = history_before {
|
||||
d.undo = undo;
|
||||
d.redo = redo;
|
||||
}
|
||||
// A group open before the command and ended or rolled back inside it stays
|
||||
// ended (its token went with the command), and keeps a step from its start to
|
||||
// the restored workbook, so Undo still returns to the state before the group.
|
||||
if !group_open
|
||||
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
|
||||
&& !Arc::ptr_eq(&wb, &d.wb)
|
||||
{
|
||||
push_entry(&mut d.undo, label, wb, &d.wb, selection);
|
||||
d.redo.clear();
|
||||
}
|
||||
d.calc.rebuild(&d.wb);
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
}
|
||||
return Err(EngineError::Internal(id.to_string(), msg));
|
||||
}
|
||||
};
|
||||
// A command that fails leaves none of its own undo groups open (a `?` between
|
||||
// `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a
|
||||
// command that succeeds keeps a group it opened (a debugger's start).
|
||||
if result.is_err() {
|
||||
self.rollback_groups_from(first_group);
|
||||
}
|
||||
if spec.journal && result.is_ok() {
|
||||
// A command that started recording is not part of the recording; one that stopped it
|
||||
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
|
||||
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
|
||||
// call is, so playing the recording back runs the command once, with its parameters.
|
||||
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
|
||||
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
|
||||
&& let Some(rec) = self.recorder.as_mut()
|
||||
{
|
||||
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) });
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// Inside an undo group the group owns the step. A command that ended a group open before
|
||||
// it leaves the group its step and makes one of its own for what it changed after the end.
|
||||
// A command that opened and closed its own groups makes one step of its own, which
|
||||
// replaces the steps those groups made.
|
||||
let epoch_changed = self.group_epoch != group_epoch;
|
||||
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
|
||||
let was_open = start.is_some_and(|(was_open, _)| was_open);
|
||||
if spec.undoable
|
||||
&& let Some((uid, wb, sel)) = before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&d.wb, &wb)
|
||||
{
|
||||
let label = spec.label.trim_end_matches('…').to_string();
|
||||
if in_group_now {
|
||||
// A group this command opened and left open (a debugger's start) takes the
|
||||
// command's start as its own, so what the command changed before opening it is
|
||||
// part of the group's step and not a step of its own.
|
||||
if !was_open
|
||||
&& let Some((_, len)) = start
|
||||
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
|
||||
{
|
||||
g.start = len;
|
||||
g.wb = wb;
|
||||
g.selection = sel;
|
||||
}
|
||||
} else if epoch_changed && was_open {
|
||||
// The group it ended made its step; changes after the end, whether direct or by
|
||||
// nested commands, are the command's own step from where the group ended.
|
||||
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
|
||||
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
|
||||
{
|
||||
d.undo.truncate(step.len);
|
||||
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
|
||||
d.redo.clear();
|
||||
}
|
||||
} else {
|
||||
let mut label = label;
|
||||
if epoch_changed && let Some((_, len)) = start {
|
||||
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
|
||||
// That is so only when the one entry the command added is a group's step from
|
||||
// the command's own start to its own result: an entry a nested command made,
|
||||
// a group step of an earlier command (undone since), or a group's step that
|
||||
// the command edited past all name the step after the command instead.
|
||||
if d.undo.len() == len + 1
|
||||
&& let Some(last) = d.undo.last()
|
||||
&& Arc::ptr_eq(&last.wb, &wb)
|
||||
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
|
||||
{
|
||||
label = last.label.clone();
|
||||
}
|
||||
d.undo.truncate(len);
|
||||
}
|
||||
push_entry(&mut d.undo, label, wb, &d.wb, sel);
|
||||
d.redo.clear();
|
||||
}
|
||||
// Inside a group the redo history waits for the group: `group_end` clears it when the
|
||||
// group becomes a step, and `group_rollback` puts it back as it was.
|
||||
d.revision += 1;
|
||||
}
|
||||
// The history limit applies once the outermost command has collapsed its steps, so the
|
||||
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
} else if let Some(uid) = doc_uid {
|
||||
self.bound_tail(uid);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f`
|
||||
/// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made
|
||||
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
|
||||
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||||
let token = self.group_begin(label)?;
|
||||
let doc = token.doc();
|
||||
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
|
||||
// (outside `execute`, which drops a command's groups itself) finds no group left open.
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
|
||||
Ok(Ok(r)) => match self.group_end(token) {
|
||||
Ok(_) => Ok(r),
|
||||
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
|
||||
// an untouched new workbook), and the group went with it: nothing is left to undo.
|
||||
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
// The error from `f` is the one to report. A rollback fails only when the group
|
||||
// has already ended, and then there is nothing left to undo.
|
||||
let _ = self.group_rollback(token);
|
||||
Err(e)
|
||||
}
|
||||
Err(panic) => {
|
||||
let _ = self.group_rollback(token);
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens an undo group on the active document that can span several calls (for example a
|
||||
/// debugger that runs one step per call). Until [`Session::group_end`] or
|
||||
/// [`Session::group_rollback`], undoable commands on that document add no undo steps of their
|
||||
/// own, and Undo and Redo are unavailable there.
|
||||
pub fn group_begin(&mut self, label: &str) -> Result<GroupToken> {
|
||||
let d = self.doc()?;
|
||||
let g = OpenGroup {
|
||||
id: self.next_group,
|
||||
doc: d.uid,
|
||||
label: label.trim_end_matches('…').to_string(),
|
||||
start: d.undo.len(),
|
||||
wb: d.wb.clone(),
|
||||
selection: d.selection.clone(),
|
||||
redo: d.redo.clone(),
|
||||
};
|
||||
let token = GroupToken { id: g.id, doc: g.doc };
|
||||
self.next_group = self.next_group.saturating_add(1);
|
||||
self.group_epoch = self.group_epoch.wrapping_add(1);
|
||||
self.groups.push(g);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is
|
||||
/// nested in another open group on the same document, which then owns the step. Groups opened
|
||||
/// inside it and still open end with it. Returns whether the workbook changed.
|
||||
pub fn group_end(&mut self, token: GroupToken) -> Result<bool> {
|
||||
let g = self.take_group(&token)?;
|
||||
let outer = self.in_group(g.doc);
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) };
|
||||
// Steps pushed directly while the group was open fold into the group's step.
|
||||
d.undo.truncate(g.start);
|
||||
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
|
||||
if changed && !outer {
|
||||
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
|
||||
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
|
||||
d.redo.clear();
|
||||
d.revision += 1;
|
||||
}
|
||||
if !outer {
|
||||
self.group_step =
|
||||
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
|
||||
}
|
||||
// Inside a command, the outermost command applies the history limit when it finishes.
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Stops the running action on the document with uid `doc`: every undo group open on it is
|
||||
/// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo
|
||||
/// and redo history as they were when the outermost one began. Their tokens are then spent
|
||||
/// (ending or rolling one back reports that it has already ended). Returns whether a group
|
||||
/// was open. `automate.stopAction` runs it on the active document.
|
||||
pub fn abort_groups(&mut self, doc: u64) -> bool {
|
||||
let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false };
|
||||
// Rolling back the outermost group ends the ones opened inside it.
|
||||
self.group_rollback(GroupToken { id, doc }).is_ok()
|
||||
}
|
||||
|
||||
/// Rolls back every open undo group numbered `first` or later (the groups a failed command
|
||||
/// opened), outermost first on each document.
|
||||
fn rollback_groups_from(&mut self, first: u64) {
|
||||
let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect();
|
||||
for (id, doc) in opened {
|
||||
// A group inside one rolled back before it is already gone.
|
||||
if self.groups.iter().any(|g| g.id == id) {
|
||||
let _ = self.group_rollback(GroupToken { id, doc });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
|
||||
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
|
||||
/// Only steps after the latest mark go: a running command's start, an open group's start and
|
||||
/// the end of the last group are positions the command or group collapses its steps by when
|
||||
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
|
||||
fn bound_tail(&mut self, uid: u64) {
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
|
||||
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
|
||||
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
|
||||
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
|
||||
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
|
||||
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
|
||||
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
|
||||
/// an open group waits for the group to end, because the group remembers where its steps start.
|
||||
fn trim_history(&mut self) {
|
||||
let groups = &self.groups;
|
||||
for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
|
||||
trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends an undo group by putting back the workbook, selection, undo and redo history as they
|
||||
/// were when it began.
|
||||
pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> {
|
||||
let g = self.take_group(&token)?;
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) };
|
||||
if !Arc::ptr_eq(&d.wb, &g.wb) {
|
||||
d.wb = g.wb;
|
||||
d.calc.rebuild(&d.wb);
|
||||
}
|
||||
d.selection = g.selection;
|
||||
d.undo.truncate(g.start);
|
||||
d.redo = g.redo;
|
||||
d.revision += 1;
|
||||
// The record of the last group step stays when the document is back exactly where that
|
||||
// step left it (this group began there); otherwise the rollback is where the group ended.
|
||||
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
|
||||
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
|
||||
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts recording top-level commands into an empty buffer, replacing any recording in
|
||||
/// progress. Queries, failed commands and commands run by other commands are not recorded.
|
||||
pub fn start_recording(&mut self) {
|
||||
self.recorder = Some(record::Recorder::default());
|
||||
}
|
||||
|
||||
/// Stops recording and returns what was recorded (`None` when nothing was recording).
|
||||
pub fn stop_recording(&mut self) -> Option<record::Recorder> {
|
||||
self.recorder.take()
|
||||
}
|
||||
|
||||
/// Whether the recording buffer is collecting commands.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.recorder.is_some()
|
||||
}
|
||||
|
||||
/// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a
|
||||
/// command run by a command, and so on.
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.depth
|
||||
}
|
||||
|
||||
/// Whether an undo group is open on the document with this uid.
|
||||
pub fn in_group(&self, doc: u64) -> bool {
|
||||
self.groups.iter().any(|g| g.doc == doc)
|
||||
}
|
||||
|
||||
/// Removes the token's group and any group on the same document opened after it.
|
||||
fn take_group(&mut self, token: &GroupToken) -> Result<OpenGroup> {
|
||||
let ended = || EngineError::Other("That undo group has already ended.".into());
|
||||
let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?;
|
||||
let mut later = self.groups.split_off(at).into_iter();
|
||||
let g = later.next().ok_or_else(ended)?;
|
||||
self.groups.extend(later.filter(|o| o.doc != g.doc));
|
||||
self.group_epoch = self.group_epoch.wrapping_add(1);
|
||||
Ok(g)
|
||||
result
|
||||
}
|
||||
|
||||
pub fn run(&mut self, id: &str, params: Json) -> std::result::Result<Json, String> {
|
||||
@@ -896,8 +468,6 @@ impl Session {
|
||||
let r = self.execute(id, params);
|
||||
let requests = self.take_ui_requests();
|
||||
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
|
||||
// A call that only opened a dialog is not recorded (see `execute`), so the recording
|
||||
// has no step to take back here.
|
||||
if r.is_ok() && asked_for_file {
|
||||
let what = match id {
|
||||
"file.open" => "`path` or `base64`",
|
||||
|
||||
@@ -803,51 +803,6 @@ 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();
|
||||
@@ -959,9 +914,6 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
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.
|
||||
@@ -1326,11 +1278,6 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
_ => 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//! The recording buffer: the commands a person (or an agent) ran while recording, each with the
|
||||
//! document, sheet and selection it ran in.
|
||||
//!
|
||||
//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a
|
||||
//! script) is one step, so playing the recording back does not run the inner commands twice. The
|
||||
//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and
|
||||
//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]).
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::Selection;
|
||||
|
||||
/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`].
|
||||
pub const RECORD_LIMIT: usize = 100_000;
|
||||
/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full
|
||||
/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so
|
||||
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
|
||||
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// One recorded command and where it ran.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordedStep {
|
||||
/// Uid of the document that was active when the command ran (0 when none was open).
|
||||
pub doc: u64,
|
||||
/// That document's active sheet (index and name) just before the command ran.
|
||||
pub sheet: usize,
|
||||
pub sheet_name: String,
|
||||
/// That document's selection just before the command ran.
|
||||
pub selection: Selection,
|
||||
/// The command id and its parameters, without secrets.
|
||||
pub id: String,
|
||||
pub params: Json,
|
||||
}
|
||||
|
||||
/// The steps recorded since recording started.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Recorder {
|
||||
pub steps: Vec<RecordedStep>,
|
||||
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
|
||||
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
|
||||
/// what ran, never a sequence with gaps.
|
||||
pub dropped: usize,
|
||||
/// The size of `steps` serialized as JSON.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Adds a step, or counts it as dropped once the buffer is full.
|
||||
pub fn push(&mut self, step: RecordedStep) {
|
||||
// Once a step has been dropped, or the step count is full, the rest are counted without
|
||||
// being serialized (a `file.open` step holds a whole file).
|
||||
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
|
||||
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
|
||||
self.bytes = self.bytes.saturating_add(size);
|
||||
self.steps.push(step);
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option<Workbook> {
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local));
|
||||
s.add_document(DocState::new(wb, None, title(name)));
|
||||
let ok = match name {
|
||||
"budget" => budget(&mut s),
|
||||
"sales" => sales(&mut s),
|
||||
|
||||
@@ -3,11 +3,6 @@ use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
thread_local! {
|
||||
/// Copies of a document's undo and redo history `execute` has taken on this thread.
|
||||
pub(crate) static HISTORY_COPIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn s() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
@@ -4604,60 +4599,3 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
|
||||
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
|
||||
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
|
||||
}
|
||||
#[test]
|
||||
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
|
||||
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
|
||||
// content edits, so the format cells may not drop a cell's `locked` flag.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "hi"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
||||
s.execute("home.fontName", json!({"name": "Arial"})).unwrap();
|
||||
let err = s.execute("home.lockCell", json!({"range": "B2", "on": false})).expect_err("unlock through formatting");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
assert!(s.execute("cell.set", json!({"cell": "B2", "input": "x"})).is_err(), "B2 stays locked");
|
||||
// The same calls run once the sheet is unprotected.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
|
||||
assert_eq!(v(&s, "B2"), Value::text("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_checks_the_protection_of_the_named_sheet() {
|
||||
// The move's protection checks must authorize the sheet it modifies, not whichever sheet
|
||||
// happens to be active while the command names another one.
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let err = s.execute("sheet.moveColumns", json!({"sheet": "P", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
let pv: Vec<Value> = {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
|
||||
(0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect()
|
||||
};
|
||||
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
|
||||
// The active, unprotected sheet moves fine.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap();
|
||||
s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::text("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_switch_windows() {
|
||||
// Stored scripts are scoped to their workbook: a step that activates another window would
|
||||
// point every later step at another open document, so it is refused before any step runs.
|
||||
let mut s = s();
|
||||
s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap();
|
||||
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
|
||||
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
|
||||
assert!(err.to_string().contains("window.activate"), "{err}");
|
||||
// A benign script runs.
|
||||
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::number(2.0));
|
||||
}
|
||||
|
||||
mod seams;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ fn opened() -> Session {
|
||||
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
|
||||
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile));
|
||||
s.add_document(DocState::new(back, None, "dynamic".into()));
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() {
|
||||
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
|
||||
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
|
||||
let mut session = Session::new();
|
||||
session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile));
|
||||
session.add_document(DocState::new(reopened, None, "chains".into()));
|
||||
session.execute("formulas.calculateNow", json!({})).expect("recalc");
|
||||
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
|
||||
|
||||
@@ -6,8 +6,7 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\
|
||||
fn with_name(name: &str, refers_to: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
// `force`: the name commands refuse GridCraft's own data names otherwise.
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -314,9 +314,7 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<u
|
||||
}
|
||||
|
||||
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
||||
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively. Numbers
|
||||
/// compare exactly, not at the 15 digits of `=` and the criteria functions (Excel 365:
|
||||
/// `MATCH(0.1+0.2,{0.3},0)` is #N/A while `0.1+0.2=0.3` is TRUE).
|
||||
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively.
|
||||
pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
|
||||
match (needle, hay) {
|
||||
(Value::Text(p), Value::Text(t)) => {
|
||||
@@ -326,7 +324,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
|
||||
compare_text(p, t) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => a == b,
|
||||
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
|
||||
(Value::Bool(a), Value::Bool(b)) => a == b,
|
||||
(Value::Error(a), Value::Error(b)) => a == b,
|
||||
(Value::Empty, Value::Empty) => true,
|
||||
@@ -372,8 +370,7 @@ mod tests {
|
||||
assert!(m("#N/A", Value::Error(CellError::NA)));
|
||||
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
|
||||
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
|
||||
assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
|
||||
assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false));
|
||||
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
|
||||
assert!(m("<>a*", Value::from("bcd")));
|
||||
assert!(m(">1/1/2020", Value::Number(44000.0)));
|
||||
// Text patterns never match blank cells, but an empty string is text.
|
||||
|
||||
@@ -13,32 +13,12 @@ use crate::{Arg, Ctx, FnSpec, VAR};
|
||||
// Time value of money
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from
|
||||
/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to
|
||||
/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan
|
||||
/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a
|
||||
/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows.
|
||||
fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 {
|
||||
if rate == 0.0 {
|
||||
return -(pv + fv) / nper;
|
||||
}
|
||||
if rate <= -1.0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let l = nper * rate.ln_1p();
|
||||
let m = l.exp_m1();
|
||||
let k = 1.0 + rate * typ;
|
||||
if m > 0.0 {
|
||||
// (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1.
|
||||
// rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow.
|
||||
-(pv * rate + (pv + fv) * (rate / m)) / k
|
||||
} else if m > -0.5 {
|
||||
// p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop.
|
||||
-(rate * (pv + fv + pv * m)) / (k * m)
|
||||
} else {
|
||||
// p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself.
|
||||
-(rate * (fv + pv * l.exp())) / (k * m)
|
||||
}
|
||||
let p = (1.0 + rate).powf(nper);
|
||||
-(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0))
|
||||
}
|
||||
|
||||
fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 {
|
||||
@@ -1810,42 +1790,6 @@ mod tests {
|
||||
is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
/// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across
|
||||
/// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`.
|
||||
#[test]
|
||||
fn pmt_matches_excel_within_a_few_ulps() {
|
||||
for (rate, nper, pv, fv, typ, excel) in [
|
||||
(0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904),
|
||||
(0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778),
|
||||
(0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775),
|
||||
(0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324),
|
||||
(0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787),
|
||||
(-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645),
|
||||
] {
|
||||
let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]);
|
||||
close_tol(got, excel, 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a
|
||||
/// tiny rate no longer loses its low digits in 1 + rate.
|
||||
#[test]
|
||||
fn pmt_edge_cases_follow_excel() {
|
||||
is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num);
|
||||
is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num);
|
||||
is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num);
|
||||
assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0));
|
||||
assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0));
|
||||
assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0));
|
||||
close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9);
|
||||
close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6);
|
||||
close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flows() {
|
||||
close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9);
|
||||
|
||||
@@ -30,7 +30,6 @@ macro_rules! f {
|
||||
category: $crate::Category::$cat,
|
||||
volatile: true,
|
||||
scalar_args: $scal,
|
||||
value_args: &[],
|
||||
signature: $sig,
|
||||
description: $desc,
|
||||
imp: {
|
||||
@@ -49,7 +48,6 @@ macro_rules! f {
|
||||
category: $crate::Category::$cat,
|
||||
volatile: false,
|
||||
scalar_args: $scal,
|
||||
value_args: &[],
|
||||
signature: $sig,
|
||||
description: $desc,
|
||||
imp: {
|
||||
@@ -158,11 +156,6 @@ pub struct FnSpec {
|
||||
/// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an
|
||||
/// empty slice means no lifting.
|
||||
pub scalar_args: &'static [bool],
|
||||
/// Parameter positions that take a single value and reject a reference to more than one
|
||||
/// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…),
|
||||
/// where other scalar parameters lift the range. Arrays that aren't references still lift.
|
||||
/// Same layout as `scalar_args`; filled at registration from [`lift::value_args`].
|
||||
pub value_args: &'static [bool],
|
||||
pub signature: &'static str,
|
||||
pub description: &'static str,
|
||||
pub imp: FnImpl,
|
||||
@@ -183,14 +176,6 @@ impl FnSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`.
|
||||
pub fn is_value_arg(&self, i: usize) -> bool {
|
||||
match self.value_args.get(i) {
|
||||
Some(b) => *b,
|
||||
None => self.value_args.last().copied().unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a call with `n` arguments is valid (Excel allows at most 255).
|
||||
pub fn accepts(&self, n: usize) -> bool {
|
||||
(self.min_args..=self.max_args).contains(&n) && n <= 255
|
||||
@@ -219,9 +204,6 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
] {
|
||||
v.extend(list);
|
||||
}
|
||||
for s in &mut v {
|
||||
s.value_args = lift::value_args(s.name);
|
||||
}
|
||||
let mut map = HashMap::with_capacity(v.len());
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
map.entry(s.name).or_insert(i);
|
||||
|
||||
@@ -6,61 +6,7 @@ use gridcraft_core::{Array, CellError, Value};
|
||||
use crate::util::MAX_CELLS;
|
||||
use crate::{Arg, Ctx, FnSpec};
|
||||
|
||||
/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more
|
||||
/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no
|
||||
/// implicit intersection), while array constants and computed arrays lift. Range parameters
|
||||
/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend
|
||||
/// (which lifts a range) are `false`.
|
||||
pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[
|
||||
// Date and time.
|
||||
("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]),
|
||||
("WORKDAY NETWORKDAYS", &[true, true, false]),
|
||||
("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]),
|
||||
// Financial.
|
||||
(
|
||||
"ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \
|
||||
DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \
|
||||
PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT",
|
||||
&[true],
|
||||
),
|
||||
("FVSCHEDULE XNPV", &[true, false]),
|
||||
("XIRR", &[false, false, true]),
|
||||
// Math and information.
|
||||
("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]),
|
||||
("SERIESSUM", &[true, true, true, false]),
|
||||
// Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here).
|
||||
(
|
||||
"BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \
|
||||
ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \
|
||||
IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \
|
||||
IMTAN",
|
||||
&[true],
|
||||
),
|
||||
];
|
||||
|
||||
/// The value-parameter mask of `name` (empty for functions without value parameters).
|
||||
pub(crate) fn value_args(name: &str) -> &'static [bool] {
|
||||
VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask)
|
||||
}
|
||||
|
||||
/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds
|
||||
/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel.
|
||||
fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
|
||||
let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1);
|
||||
if !args.iter().enumerate().any(|(i, a)| multi(i, a)) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
args.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
|
||||
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
|
||||
let args = rejected.as_deref().unwrap_or(args);
|
||||
let mut rows = 0usize;
|
||||
let mut cols = 0usize;
|
||||
let mut any = false;
|
||||
@@ -148,37 +94,4 @@ mod tests {
|
||||
);
|
||||
is_err(huge, CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_params_reject_multi_cell_references() {
|
||||
let s = FnSpec { value_args: &[true, false], ..spec() };
|
||||
let mut ctx = TestCtx::default();
|
||||
// A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine.
|
||||
is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value);
|
||||
assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0));
|
||||
assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]);
|
||||
// The rejected reference adds no shape: the other argument still lifts.
|
||||
let v = Value::Error(CellError::Value);
|
||||
assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]);
|
||||
// A position that isn't a value parameter still lifts the reference.
|
||||
assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_arg_table_names_registered_scalar_positions() {
|
||||
for (names, mask) in super::VALUE_ARGS {
|
||||
assert!(!mask.is_empty());
|
||||
for name in names.split_ascii_whitespace() {
|
||||
let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") };
|
||||
assert_eq!(s.value_args, *mask, "{name}");
|
||||
for i in 0..s.max_args.min(12) {
|
||||
assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2)));
|
||||
assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2)));
|
||||
assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty()));
|
||||
assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,19 +23,6 @@ fn needle(args: &[Arg], i: usize) -> R<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Order of two values for the approximate and binary searches: [`compare`], except that numbers
|
||||
/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its
|
||||
/// lookups don't. Observed in Excel 365 with A1 holding
|
||||
/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because
|
||||
/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4.
|
||||
/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode.
|
||||
fn lookup_order(a: &Value, b: &Value) -> Ordering {
|
||||
match (a, b) {
|
||||
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or_else(|| compare(a, b)),
|
||||
_ => compare(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn same_type(a: &Value, b: &Value) -> bool {
|
||||
matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_)))
|
||||
}
|
||||
@@ -72,10 +59,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
continue;
|
||||
};
|
||||
let v = hay.get(j)?;
|
||||
let o = lookup_order(v, x);
|
||||
let o = compare(v, x);
|
||||
if o == Ordering::Equal {
|
||||
let mut k = j;
|
||||
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) {
|
||||
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
|
||||
k += 1;
|
||||
}
|
||||
return Some(k);
|
||||
@@ -101,8 +88,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
|
||||
match v {
|
||||
Value::Empty => Ordering::Greater,
|
||||
_ if desc => lookup_order(v, x).reverse(),
|
||||
_ => lookup_order(v, x),
|
||||
_ if desc => compare(v, x).reverse(),
|
||||
_ => compare(v, x),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +262,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
|
||||
if !same_type(x, v) {
|
||||
continue;
|
||||
}
|
||||
let o = lookup_order(v, x);
|
||||
let o = compare(v, x);
|
||||
let candidate = if match_mode == -1 { o == Ordering::Less } else { o == Ordering::Greater };
|
||||
if !candidate {
|
||||
continue;
|
||||
@@ -283,7 +270,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
|
||||
let better = match best.and_then(|b| hay.get(b)) {
|
||||
None => true,
|
||||
Some(bv) => {
|
||||
let c = lookup_order(v, bv);
|
||||
let c = compare(v, bv);
|
||||
if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less }
|
||||
}
|
||||
};
|
||||
@@ -761,44 +748,6 @@ mod tests {
|
||||
assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]);
|
||||
}
|
||||
|
||||
/// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365.
|
||||
#[test]
|
||||
fn lookups_compare_numbers_exactly() {
|
||||
let e = 0.1 + 0.2;
|
||||
let one = || rf(col(&[e]));
|
||||
// Exact modes miss a key one bit away.
|
||||
is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA);
|
||||
is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA);
|
||||
is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA);
|
||||
close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0);
|
||||
close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0);
|
||||
is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA);
|
||||
// 0 and -0 are still the same key.
|
||||
close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0);
|
||||
// Approximate modes: 0.3 sorts below 0.30000000000000004.
|
||||
is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA);
|
||||
close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0);
|
||||
is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA);
|
||||
is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA);
|
||||
close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0);
|
||||
// The bisection stops on 0.3 and doesn't run on into the next entry.
|
||||
let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4]));
|
||||
close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0);
|
||||
close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0);
|
||||
close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0);
|
||||
close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0);
|
||||
let asc = || rf(col(&[0.1, e, 0.5]));
|
||||
let desc = || rf(col(&[0.5, e, 0.1]));
|
||||
close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0);
|
||||
close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0);
|
||||
is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0);
|
||||
close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index() {
|
||||
let t3 = table_rows3();
|
||||
|
||||
@@ -306,10 +306,12 @@ fn modulo(x: f64, d: f64) -> R<f64> {
|
||||
if d == 0.0 {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
// `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors.
|
||||
let r = x % d;
|
||||
let q = (x / d).floor();
|
||||
let r = x - d * q;
|
||||
// Keep the sign of the divisor and avoid returning |d| from rounding.
|
||||
let r = if r != 0.0 && (r < 0.0) != (d < 0.0) { r + d } else { r };
|
||||
if r != 0.0 && (r < 0.0) != (d < 0.0) {
|
||||
return Ok(r + d);
|
||||
}
|
||||
if r.abs() >= d.abs() {
|
||||
return Ok(0.0);
|
||||
}
|
||||
@@ -1440,19 +1442,6 @@ 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);
|
||||
|
||||
@@ -334,8 +334,6 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if seen_dec {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
} else if c == '.' || c == ',' {
|
||||
return Err(CellError::Value);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
@@ -1213,20 +1211,11 @@ mod tests {
|
||||
close(ev("VALUE", vec![t("12:00")]), 0.5);
|
||||
close(ev("VALUE", vec![t("2020-01-01")]), 43831.0);
|
||||
is_err(ev("VALUE", vec![t("abc")]), CellError::Value);
|
||||
// Accounting negatives, as Excel 365 reads them.
|
||||
close(ev("VALUE", vec![t("$(5)")]), -5.0);
|
||||
close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5);
|
||||
close(ev("VALUE", vec![t("(5)%")]), -0.05);
|
||||
for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] {
|
||||
is_err(ev("VALUE", vec![t(s)]), CellError::Value);
|
||||
}
|
||||
close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27);
|
||||
close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035);
|
||||
close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0);
|
||||
close(ev("NUMBERVALUE", vec![t("")]), 0.0);
|
||||
is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value);
|
||||
is_err(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), CellError::Value);
|
||||
is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(" ")]), CellError::Value);
|
||||
is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6");
|
||||
is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230");
|
||||
is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6");
|
||||
|
||||
@@ -1084,8 +1084,11 @@ 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,7 +7,6 @@
|
||||
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||
use serde_json::{Map, Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
@@ -45,28 +44,6 @@ pub struct Dialog {
|
||||
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
|
||||
back: Option<Box<Dialog>>,
|
||||
focus_initialized: bool,
|
||||
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
|
||||
pick: Option<RefPick>,
|
||||
}
|
||||
|
||||
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
|
||||
/// reference at the caret instead of selecting it, and a drag grows it into a range.
|
||||
#[derive(Clone, Debug)]
|
||||
struct RefPick {
|
||||
key: &'static str,
|
||||
/// The sheet the dialog was opened on, by name: its index changes when sheets are inserted or
|
||||
/// moved while the dialog is open. References to another sheet carry that sheet's name.
|
||||
sheet: String,
|
||||
/// Caret (char index), mirrored from the text box while it has the keyboard.
|
||||
caret: usize,
|
||||
/// The reference last inserted (byte span), replaced by the next click or the drag.
|
||||
point: Option<(usize, usize)>,
|
||||
anchor: CellRef,
|
||||
/// Give the box the keyboard back, caret after the reference, on the next frame.
|
||||
refocus: bool,
|
||||
/// The selection the rule applies to, fixed when the dialog opened as Excel's "Applies to":
|
||||
/// pointing on another sheet must not move the rule there.
|
||||
applies_to: String,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
@@ -100,46 +77,6 @@ impl Dialog {
|
||||
self.values.insert(key.into(), value);
|
||||
}
|
||||
|
||||
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
|
||||
pub fn picks_references(&self) -> bool {
|
||||
self.pick.is_some()
|
||||
}
|
||||
|
||||
/// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the
|
||||
/// formula box's caret, replacing the reference the press inserted, as in the formula bar's
|
||||
/// point mode. An empty box starts with `=`. After an operand there is no place for a
|
||||
/// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the
|
||||
/// dialog's own.
|
||||
pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) {
|
||||
let Some(pick) = self.pick.as_mut() else { return };
|
||||
let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let mut ed = crate::editor::EditState::new(0, cell, text, true, false);
|
||||
ed.caret = pick.caret.min(ed.text.chars().count());
|
||||
ed.point = pick.point;
|
||||
if ed.text.trim().is_empty() {
|
||||
ed.text = "=".into();
|
||||
ed.caret = 1;
|
||||
ed.point = None;
|
||||
}
|
||||
if !ed.can_point() {
|
||||
return;
|
||||
}
|
||||
if !extend {
|
||||
pick.anchor = cell;
|
||||
}
|
||||
let range = abs(&RangeRef::new(pick.anchor, cell));
|
||||
let reference = if active_sheet.eq_ignore_ascii_case(&pick.sheet) {
|
||||
range
|
||||
} else {
|
||||
format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet))
|
||||
};
|
||||
ed.insert_ref(&reference);
|
||||
pick.caret = ed.caret;
|
||||
pick.point = ed.point;
|
||||
pick.refocus = true;
|
||||
self.values.insert(pick.key.into(), json!(ed.text));
|
||||
}
|
||||
|
||||
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
|
||||
Dialog {
|
||||
name: name.into(),
|
||||
@@ -157,7 +94,6 @@ impl Dialog {
|
||||
picture_target: None,
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +114,6 @@ impl Dialog {
|
||||
picture_target: None,
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,19 +690,6 @@ impl Dialog {
|
||||
fields,
|
||||
json!({"preset": "lightRedFillDarkRedText", "period": "today"}),
|
||||
);
|
||||
if ty == "expression" {
|
||||
let (sheet, applies_to) = app
|
||||
.session
|
||||
.active()
|
||||
.map(|doc| {
|
||||
(
|
||||
doc.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
|
||||
doc.selection.ranges.iter().map(RangeRef::a1).collect::<Vec<_>>().join(","),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to });
|
||||
}
|
||||
d.values.insert("type".into(), json!(ty));
|
||||
d.values.insert("operator".into(), p("operator"));
|
||||
d
|
||||
@@ -1193,10 +1115,6 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
|
||||
rule.insert(k.clone(), val.clone());
|
||||
}
|
||||
v = json!({"rule": Json::Object(rule)});
|
||||
if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) {
|
||||
v["sheet"] = json!(pick.sheet.as_str());
|
||||
v["range"] = json!(pick.applies_to);
|
||||
}
|
||||
}
|
||||
"series" => {
|
||||
if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::<f64>().ok()) {
|
||||
@@ -1716,25 +1634,6 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
|
||||
d.focus_first_field = false;
|
||||
}
|
||||
|
||||
/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a
|
||||
/// reference in it (the click took the focus), its caret after the reference.
|
||||
fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {
|
||||
if typed {
|
||||
pick.point = None;
|
||||
}
|
||||
if pick.refocus {
|
||||
let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret))));
|
||||
state.store(&r.ctx, r.id);
|
||||
r.request_focus();
|
||||
pick.refocus = false;
|
||||
} else if r.has_focus()
|
||||
&& let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range())
|
||||
{
|
||||
pick.caret = range.primary.index.into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a dialog's `fields`, editing its values.
|
||||
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Field labels, choices and notes are English source strings, translated as they are drawn.
|
||||
@@ -1760,9 +1659,6 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
if r.changed() {
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) {
|
||||
track_pick(pick, &r, r.changed());
|
||||
}
|
||||
});
|
||||
}
|
||||
Field::Password { key, label } => {
|
||||
|
||||
@@ -105,6 +105,13 @@ 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 {
|
||||
|
||||
@@ -1300,9 +1300,7 @@ pub(crate) fn paint_quadrant(
|
||||
// Numbers and dates never overflow: they show #### when too narrow.
|
||||
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;
|
||||
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
|
||||
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
|
||||
w = galley.size().x;
|
||||
}
|
||||
@@ -2336,19 +2334,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p)));
|
||||
return;
|
||||
}
|
||||
// A dialog's formula box in point mode (New Formatting Rule) takes the reference; the
|
||||
// selection, which the dialog acts on, stays.
|
||||
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
|
||||
d.pick_reference(c, &sh.name, false);
|
||||
app.grid.drag = Drag::Select;
|
||||
app.grid.drag_select = true;
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Point mode while editing a formula: clicking inserts a reference.
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.can_point()
|
||||
@@ -2401,20 +2386,6 @@ 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;
|
||||
@@ -2426,10 +2397,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
d.selection.anchor = sel.anchor;
|
||||
}
|
||||
} else if mods.command {
|
||||
// Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.
|
||||
// Ctrl+click 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);
|
||||
@@ -2671,10 +2641,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while
|
||||
/// pointing a formula reference, which also drags a Select).
|
||||
fn format_painter_apply(app: &mut SheetApp) {
|
||||
if app.session.format_painter.is_some()
|
||||
&& !app.editor.as_ref().is_some_and(|ed| ed.can_point())
|
||||
&& !app.dialog.as_ref().is_some_and(|d| d.picks_references())
|
||||
{
|
||||
if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) {
|
||||
let dest = app.session.active().map(|d| d.selection.current().a1());
|
||||
if let Some(dest) = dest
|
||||
&& let Err(e) = app.run("edit.formatPainter", json!({"apply": dest}))
|
||||
@@ -2727,9 +2694,7 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
|
||||
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
||||
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
|
||||
);
|
||||
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
|
||||
d.pick_reference(cell, &sh.name, true);
|
||||
} else if let Some(ed) = app.editor.as_mut()
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.point.is_some()
|
||||
{
|
||||
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) {
|
||||
@@ -3122,19 +3087,6 @@ 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 {
|
||||
@@ -3161,7 +3113,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() };
|
||||
(editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h)
|
||||
((text_w + 14.0).max(rect.width()).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)));
|
||||
@@ -3443,8 +3395,7 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
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) {
|
||||
if !geo.cells.contains_rect(button) {
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
@@ -4051,23 +4002,6 @@ 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};
|
||||
|
||||
+190
-18
@@ -321,14 +321,21 @@ fn name_sort_key(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Help tables are sorted and unique: lookups binary-search them.
|
||||
/// Help tables are sorted, unique, and keyed by text the function catalog still has.
|
||||
#[test]
|
||||
fn function_help_tables_are_sorted_and_unique() {
|
||||
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();
|
||||
for language in Language::ALL {
|
||||
let Some((description_table, argument_table)) = language.function_help() else { continue };
|
||||
for table in [description_table, argument_table] {
|
||||
for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -336,27 +343,105 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untranslated_text_falls_back_to_english() {
|
||||
let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label);
|
||||
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));
|
||||
}
|
||||
for (english, _) in ja::CORE {
|
||||
assert_eq!(Language::En.tr(english), label(english));
|
||||
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}");
|
||||
}
|
||||
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");
|
||||
// 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::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.
|
||||
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");
|
||||
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.
|
||||
/// 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é").
|
||||
#[test]
|
||||
fn translations_keep_the_shape_of_their_english() {
|
||||
let placeholders =
|
||||
@@ -372,19 +457,106 @@ 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).
|
||||
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 {
|
||||
for language in Language::ALL {
|
||||
for (english, _) in ja::CORE {
|
||||
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]
|
||||
|
||||
+130
-12
@@ -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 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.
|
||||
//! [`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.
|
||||
|
||||
/// English label → Japanese, the shared reference set.
|
||||
/// English label → Japanese, the shared reference set every catalog covers.
|
||||
pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Paste", "貼り付け"),
|
||||
("Paste Values", "値の貼り付け"),
|
||||
@@ -1150,17 +1150,99 @@ mod tests {
|
||||
DIALOGS.iter().chain(CORE).map(|(english, _)| *english)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_unique_and_no_value_is_blank() {
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in DIALOGS.iter().chain(CORE) {
|
||||
assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}");
|
||||
assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {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 extra_labels_do_not_overlap_the_catalog() {
|
||||
fn keys_are_unique_and_every_value_is_translated() {
|
||||
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 extra_labels_are_translated_and_do_not_overlap_the_catalog() {
|
||||
let catalog: HashSet<&str> = catalog_keys().collect();
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in EXTRA {
|
||||
@@ -1170,4 +1252,40 @@ mod tests {
|
||||
assert_eq!(translate(english), Some(*japanese));
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,7 @@ pub mod keytip_table;
|
||||
pub enum Action {
|
||||
Tab(&'static str),
|
||||
Command(&'static str),
|
||||
/// A command run with fixed params (a JSON object literal from keytips.json `params`), for
|
||||
/// menu items that share one command id, like the Borders presets (HBO bottom, HBN none).
|
||||
CommandWith(&'static str, &'static str),
|
||||
Dialog(&'static str),
|
||||
/// A dialog opened with fixed params, like Format Cells on its Border tab (HBM).
|
||||
DialogWith(&'static str, &'static str),
|
||||
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
|
||||
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
|
||||
Pending(&'static str),
|
||||
|
||||
@@ -50,8 +50,8 @@ pub fn is_group_level(seq: &str) -> bool {
|
||||
|
||||
fn matches_target(action: &Action, target: &str) -> bool {
|
||||
match action {
|
||||
Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target,
|
||||
Action::Dialog(name) | Action::DialogWith(name, _) => format!("dialog:{name}") == target,
|
||||
Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target,
|
||||
Action::Dialog(name) => format!("dialog:{name}") == target,
|
||||
Action::Tab(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,40 +38,40 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins)
|
||||
("HB", Entry::Continue), // Excel: Borders Menu; group level:
|
||||
("HBA", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"all\"}"))), // Excel: All Borders; Borders menu item
|
||||
("HBB", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"doubleBottom\"}"))), // Excel: Bottom Double Border; Borders menu item
|
||||
("HBC", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topThickBottom\"}"))), // Excel: Top and Thick Bottom Border; Borders menu item
|
||||
("HBD", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topBottom\"}"))), // Excel: Top and Bottom Border; Borders menu item
|
||||
("HBE", Entry::Finish(Action::Pending("home.eraseBorder"))), // pending; Excel: Erase Border; pending: Excel arms an eraser pen; GridCraft has no border pen mode yet
|
||||
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
|
||||
("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item
|
||||
("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu
|
||||
("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
|
||||
("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
|
||||
("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
|
||||
("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item
|
||||
("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
|
||||
("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
|
||||
("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item
|
||||
("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item
|
||||
("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item
|
||||
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
|
||||
("HBY", Entry::Finish(Action::Pending("home.borderLineStyle"))), // pending; Excel: Line Style; pending: Line Style submenu
|
||||
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
|
||||
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
|
||||
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
|
||||
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
|
||||
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
|
||||
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
|
||||
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
|
||||
("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders)
|
||||
("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD
|
||||
("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD
|
||||
("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD
|
||||
("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD
|
||||
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
|
||||
("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD
|
||||
("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD)
|
||||
("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD
|
||||
("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders)
|
||||
("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD
|
||||
("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD
|
||||
("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD
|
||||
("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD
|
||||
("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders)
|
||||
("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD
|
||||
("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD
|
||||
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
|
||||
("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD)
|
||||
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
|
||||
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
|
||||
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
|
||||
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
|
||||
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
|
||||
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
|
||||
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
|
||||
("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks
|
||||
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
|
||||
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
|
||||
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
|
||||
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
|
||||
("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting)
|
||||
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
|
||||
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
|
||||
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
|
||||
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
|
||||
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
|
||||
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
|
||||
("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands
|
||||
("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane
|
||||
("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
" \"continue\" (an explicit group level with no deeper sequences yet).",
|
||||
" - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.",
|
||||
" - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.",
|
||||
" - params: an optional JSON object passed to a command or dialog, for menu items that share one id (the Borders presets: HBO {\"preset\": \"bottom\"}).",
|
||||
" - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)."
|
||||
],
|
||||
"sequences": [
|
||||
@@ -204,49 +203,36 @@
|
||||
"seq": "HBA",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "all"
|
||||
},
|
||||
"excel": "All Borders",
|
||||
"note": "Borders menu item"
|
||||
"note": "containment(Borders)"
|
||||
},
|
||||
{
|
||||
"seq": "HBB",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "doubleBottom"
|
||||
},
|
||||
"excel": "Bottom Double Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBC",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "topThickBottom"
|
||||
},
|
||||
"excel": "Top and Thick Bottom Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBD",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "topBottom"
|
||||
},
|
||||
"excel": "Top and Bottom Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBE",
|
||||
"kind": "command",
|
||||
"target": "home.eraseBorder",
|
||||
"pending": true,
|
||||
"target": "home.borders",
|
||||
"excel": "Erase Border",
|
||||
"note": "pending: Excel arms an eraser pen; GridCraft has no border pen mode yet"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBG",
|
||||
@@ -259,109 +245,78 @@
|
||||
"seq": "HBH",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "thickBottom"
|
||||
},
|
||||
"excel": "Thick Bottom Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBI",
|
||||
"kind": "command",
|
||||
"target": "home.borderLineColor",
|
||||
"pending": true,
|
||||
"target": "home.borders",
|
||||
"excel": "Line Color",
|
||||
"note": "pending: Line Color submenu"
|
||||
"note": "Line Color (submenu TBD)"
|
||||
},
|
||||
{
|
||||
"seq": "HBL",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "left"
|
||||
},
|
||||
"excel": "Left Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBM",
|
||||
"kind": "dialog",
|
||||
"target": "formatCells",
|
||||
"params": {
|
||||
"tab": "Border"
|
||||
},
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"excel": "More Borders",
|
||||
"note": "Format Cells on its Border tab"
|
||||
"note": "containment(Borders)"
|
||||
},
|
||||
{
|
||||
"seq": "HBN",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "none"
|
||||
},
|
||||
"excel": "No Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBO",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "bottom"
|
||||
},
|
||||
"excel": "Bottom Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBP",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "top"
|
||||
},
|
||||
"excel": "Top Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBR",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "right"
|
||||
},
|
||||
"excel": "Right Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBS",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "outside"
|
||||
},
|
||||
"excel": "Outside Borders",
|
||||
"note": "Borders menu item"
|
||||
"note": "containment(Borders)"
|
||||
},
|
||||
{
|
||||
"seq": "HBT",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "thickOutside"
|
||||
},
|
||||
"excel": "Thick Outside Borders",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBU",
|
||||
"kind": "command",
|
||||
"target": "home.borders",
|
||||
"params": {
|
||||
"preset": "topDoubleBottom"
|
||||
},
|
||||
"excel": "Top and Double Bottom Border",
|
||||
"note": "Borders menu item"
|
||||
"note": "border-side TBD"
|
||||
},
|
||||
{
|
||||
"seq": "HBW",
|
||||
@@ -373,10 +328,9 @@
|
||||
{
|
||||
"seq": "HBY",
|
||||
"kind": "command",
|
||||
"target": "home.borderLineStyle",
|
||||
"pending": true,
|
||||
"target": "home.borders",
|
||||
"excel": "Line Style",
|
||||
"note": "pending: Line Style submenu"
|
||||
"note": "Line Style (submenu TBD)"
|
||||
},
|
||||
{
|
||||
"seq": "HC",
|
||||
|
||||
@@ -117,14 +117,6 @@ 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
|
||||
@@ -1392,19 +1384,7 @@ impl SheetApp {
|
||||
keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id),
|
||||
keytips::Action::Command("edit.paste") => self.paste_from_clipboard(),
|
||||
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
|
||||
keytips::Action::CommandWith(id, params) => {
|
||||
let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({}));
|
||||
// A Borders preset picked by key tip becomes the split button's face, as a
|
||||
// click on the same menu item does.
|
||||
if id == "home.borders"
|
||||
&& let Some(preset) = params.get("preset").and_then(Json::as_str)
|
||||
{
|
||||
self.grid.last_border = preset.to_string();
|
||||
}
|
||||
self.run_or_alert(id, params);
|
||||
}
|
||||
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
|
||||
keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))),
|
||||
// Reserved sequence: fires silently while its command is missing. When the
|
||||
// command lands in the catalog it goes live with no table edit.
|
||||
keytips::Action::Pending(id) => {
|
||||
|
||||
@@ -74,23 +74,15 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
{
|
||||
app.run_or_alert("file.save", json!({}));
|
||||
}
|
||||
// The commands' own availability: there must be a step, and no undo group may be open on
|
||||
// the document (an action that spans several calls is still running).
|
||||
let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok();
|
||||
let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok();
|
||||
// Named and marked enabled or not for assistive technology (and the UI tests).
|
||||
let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch));
|
||||
undo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_undo, "Undo"));
|
||||
if undo.clicked() && can_undo {
|
||||
let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty());
|
||||
let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty());
|
||||
if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)).clicked() && can_undo {
|
||||
app.run_or_alert("edit.undo", json!({}));
|
||||
}
|
||||
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch));
|
||||
egui::Popup::menu(&undo_list).show(|ui| {
|
||||
let labels: Vec<String> = if can_undo {
|
||||
app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let labels: Vec<String> =
|
||||
app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default();
|
||||
if labels.is_empty() {
|
||||
ui.label("Can't Undo");
|
||||
}
|
||||
@@ -100,9 +92,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch));
|
||||
redo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_redo, "Redo"));
|
||||
if redo.clicked() && can_redo {
|
||||
if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)).clicked() && can_redo {
|
||||
app.run_or_alert("edit.redo", json!({}));
|
||||
}
|
||||
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch));
|
||||
@@ -214,11 +204,6 @@ 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 }))
|
||||
@@ -337,8 +322,6 @@ 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()
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
//! New Formatting Rule ▸ formula: clicking or dragging on the grid writes the absolute reference
|
||||
//! into the formula box at its caret, as in Excel, and leaves the selection the rule applies to alone.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("home.insertSheet", json!({})).unwrap();
|
||||
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "F8:G9"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"}));
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn formula(h: &Harness) -> String {
|
||||
h.state().dialog.as_ref().and_then(|d| d.values.get("formula")).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn selection(h: &Harness) -> String {
|
||||
h.state().session.active().unwrap().selection.current().a1()
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
for c in text.chars() {
|
||||
h.input_mut().events.push(Event::Text(c.to_string()));
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_center(h: &Harness, a1: &str) -> Pos2 {
|
||||
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 c = gridcraft_engine::core::CellRef::parse(a1).unwrap();
|
||||
pos2(geo.x(sheet, c.col) + sheet.col_width(c.col) * geo.z * 0.5, geo.y(sheet, c.row) + sheet.row_height(c.row) * geo.z * 0.5)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, a1: &str) {
|
||||
let pos = cell_center(h, a1);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness, from: &str, to: &str) {
|
||||
let (a, b) = (cell_center(h, from), cell_center(h, to));
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(a), Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]);
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
let p = a + (b - a) * (i as f32 / 6.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_cell_after_equals_inserts_its_absolute_reference() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
assert_eq!(formula(&h), "=", "the formula box had the keyboard");
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(formula(&h), "=$B$2");
|
||||
assert_eq!(selection(&h), "F8:G9", "the range the rule applies to is still selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_cell_in_an_empty_box_starts_the_formula() {
|
||||
let mut h = harness();
|
||||
click(&mut h, "C3");
|
||||
assert_eq!(formula(&h), "=$C$3");
|
||||
assert_eq!(selection(&h), "F8:G9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_click_replaces_the_reference_just_inserted() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
click(&mut h, "B2");
|
||||
click(&mut h, "D5");
|
||||
assert_eq!(formula(&h), "=$D$5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inserts_a_range() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
drag(&mut h, "B2", "C4");
|
||||
assert_eq!(formula(&h), "=$B$2:$C$4");
|
||||
assert_eq!(selection(&h), "F8:G9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reference_goes_at_the_caret_and_typing_continues_after_it() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=SUM()");
|
||||
h.key_press(Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(formula(&h), "=SUM($B$2)");
|
||||
type_text(&mut h, "+1");
|
||||
assert_eq!(formula(&h), "=SUM($B$2+1)", "the box has the keyboard back, its caret after the reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_after_an_operand_changes_nothing() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=A1>5");
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(formula(&h), "=A1>5");
|
||||
assert_eq!(selection(&h), "F8:G9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_applies_the_picked_formula_to_the_selection() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
click(&mut h, "B2");
|
||||
type_text(&mut h, ">0");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let cf = sheet.cond_formats.last().expect("a rule was added");
|
||||
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
|
||||
assert!(format!("{:?}", cf.rule).contains("$B$2>0"), "rule: {:?}", cf.rule);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_dialogs_leave_grid_clicks_alone() {
|
||||
let mut h = harness();
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"}));
|
||||
h.run_steps(6);
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointing_on_another_sheet_keeps_the_rule_on_the_sheet_it_was_opened_for() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
click(&mut h, "A1");
|
||||
assert_eq!(formula(&h), "=Sheet2!$A$1");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
assert!(wb.sheet(1).unwrap().cond_formats.is_empty(), "nothing lands on the sheet pointed at");
|
||||
let cf = wb.sheet(0).unwrap().cond_formats.last().expect("the rule is on the sheet the dialog was opened on");
|
||||
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rule_follows_its_sheet_when_sheets_move_while_the_dialog_is_open() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
click(&mut h, "B2");
|
||||
h.state_mut().session.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
let names = |h: &Harness| h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&h), ["Sheet2", "Sheet1"], "Sheet1 now sits at index 1");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
let sheet1 = wb.sheets.iter().find(|s| s.name == "Sheet1").unwrap();
|
||||
let sheet2 = wb.sheets.iter().find(|s| s.name == "Sheet2").unwrap();
|
||||
assert!(sheet2.cond_formats.is_empty(), "nothing lands on the sheet that took index 0");
|
||||
let cf = sheet1.cond_formats.last().expect("the rule stays on Sheet1");
|
||||
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//! 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,7 +4,6 @@ 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> {
|
||||
@@ -109,6 +108,54 @@ fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() {
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
|
||||
}
|
||||
|
||||
/// Where the title bar's icon button for `name` is. Its icon has no text, so it is found by
|
||||
/// hovering along the title bar until its tooltip ("Save (⌘S)") shows.
|
||||
fn title_bar_button(h: &mut egui_kittest::Harness<'static, SheetApp>, name: &str) -> egui::Pos2 {
|
||||
let autosave = h.get_by_label("AutoSave").rect();
|
||||
let tip = format!("{name} (");
|
||||
let mut x = autosave.right();
|
||||
while x < autosave.right() + 300.0 {
|
||||
let at = egui::pos2(x, autosave.center().y);
|
||||
h.hover_at(at);
|
||||
h.run_steps(3);
|
||||
if h.query_by_label_contains(&tip).is_some() {
|
||||
return at;
|
||||
}
|
||||
x += 6.0;
|
||||
}
|
||||
panic!("no title bar button shows a {tip:?} tooltip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_title_bar_save_button_saves_the_cell_being_typed() {
|
||||
// Regression test for #223: the title bar's Save button saved the file without the cell the
|
||||
// user was still typing in.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-title-bar-save-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Book.xlsx").to_string_lossy().into_owned();
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("123".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().editor.is_some());
|
||||
let save = title_bar_button(&mut h, "Save");
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(egui::Event::PointerButton {
|
||||
pos: save,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.state().editor.is_none(), "Save commits the cell draft");
|
||||
let (wb, _) = gridcraft_engine::io::open_bytes("Book.xlsx", &std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0), "the saved file has the typed value");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
let mut h = harness(Services::default());
|
||||
@@ -135,25 +182,30 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
|
||||
#[test]
|
||||
fn translated_options_headings_show_no_context_prefix() {
|
||||
for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] {
|
||||
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")),
|
||||
] {
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
|
||||
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
|
||||
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some("options"));
|
||||
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");
|
||||
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");
|
||||
}
|
||||
// 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);
|
||||
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}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,65 +209,3 @@ fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() {
|
||||
let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap());
|
||||
assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner");
|
||||
}
|
||||
|
||||
/// Right-to-left sheet with column A made `chars` wide (so column B's right edge, where the fill
|
||||
/// range B1:B6 ends, sits far enough right to be scrolled to either edge of the cells area).
|
||||
fn rtl_wide_a(chars: u32) -> Harness {
|
||||
harness(|s| {
|
||||
one_two(s);
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": chars})).unwrap();
|
||||
s.execute("view.rightToLeft", json!({"on": true})).unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
/// Logical right edge of the filled column B (scroll-dependent) and the current geometry.
|
||||
fn b_right(h: &Harness) -> (f32, Geo) {
|
||||
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);
|
||||
(geo.cell_rect(sheet, CellRef::parse("B6").unwrap()).right(), geo)
|
||||
}
|
||||
|
||||
fn scroll_b_right_to(h: &mut Harness, logical_x: f32) {
|
||||
let (now, geo) = b_right(h);
|
||||
h.state_mut().view_mut().unwrap().scroll.x += (now - logical_x) / geo.z;
|
||||
h.run_steps(3);
|
||||
let got = b_right(h).0;
|
||||
assert!((got - logical_x).abs() < 1.0, "setup: column B ends at logical x {got}, wanted {logical_x}");
|
||||
}
|
||||
|
||||
/// Logical x of the range's corner is `cells.right() - 25`: the 18 px button (at corner + 2 ..
|
||||
/// corner + 20) fits in the cells area, so on screen it sits at the left edge of the mirrored cells
|
||||
/// area, left of the filled range. `fill_options_tag` tested the mirrored button against the
|
||||
/// unmirrored cells, i.e. against the row-heading strip it does not own, and hid it.
|
||||
#[test]
|
||||
fn the_button_shows_near_the_left_screen_edge_on_a_right_to_left_sheet() {
|
||||
let mut h = rtl_wide_a(220);
|
||||
let (_, geo) = b_right(&h);
|
||||
scroll_b_right_to(&mut h, geo.cells.right() - 25.0);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
|
||||
let (_, geo) = b_right(&h);
|
||||
let cells = h.state().grid.cells_rect.unwrap();
|
||||
assert!(geo.header_w > 20.0 && geo.cells.right() - 25.0 + 20.0 > geo.cells.right() - geo.header_w, "setup: in the zone the old check hides");
|
||||
let button = h.query_by_label("Auto Fill Options").expect("the button fits inside the cells area, so it shows").rect();
|
||||
assert!(cells.contains_rect(button), "{button:?} outside {cells:?}");
|
||||
}
|
||||
|
||||
/// The corner is 10 px left of `cells.left()` (scrolled partly behind the row headings, which are
|
||||
/// on the right on screen). The button would start 8 px behind the headings, so it must not be
|
||||
/// drawn there. The old check let it through because the mirrored button fits the unmirrored cells.
|
||||
#[test]
|
||||
fn the_button_stays_off_the_row_headings_on_a_right_to_left_sheet() {
|
||||
let mut h = rtl_wide_a(20);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_some(), "setup: the button shows before scrolling");
|
||||
let (_, geo) = b_right(&h);
|
||||
scroll_b_right_to(&mut h, geo.cells.left() - 10.0);
|
||||
|
||||
let cells = h.state().grid.cells_rect.unwrap();
|
||||
if let Some(b) = h.query_by_label("Auto Fill Options") {
|
||||
let button = b.rect();
|
||||
assert!(cells.contains_rect(button), "{button:?} overlaps the row headings, cells area is {cells:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,12 +176,14 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"pt-BR"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::PtBr);
|
||||
assert_eq!(app.ui.language.tr("Data"), "Dados");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
assert!(!enter(&mut app, "=SUMA(1;2)"));
|
||||
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::PtBr);
|
||||
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,6 +193,7 @@ fn czech_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Cs);
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Vzorce");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
@@ -222,6 +225,8 @@ fn turkish_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"tr-TR"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Tr);
|
||||
assert_eq!(app.ui.language.tr("Data"), "Veri");
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Formüller");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
|
||||
@@ -83,15 +83,3 @@ fn ctrl_click_beside_the_link_text_still_extends_the_selection() {
|
||||
assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection");
|
||||
assert!(h.state().dialog.is_none(), "no link was followed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_click_on_the_link_text_still_extends_the_selection() {
|
||||
let mut h = harness();
|
||||
click_at(&mut h, "B2", 0.9, Modifiers::NONE);
|
||||
click_at(&mut h, "B4", 0.3, Modifiers { alt: true, ..Modifiers::COMMAND });
|
||||
let d = h.state().session.active().unwrap();
|
||||
let ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
|
||||
assert_eq!(ranges, ["B2", "B4"], "Ctrl+Alt+click adds the hyperlink cell to the selection");
|
||||
assert_eq!(d.selection.active.a1(), "B4", "the link was not followed");
|
||||
assert!(h.state().dialog.is_none(), "no link dialog");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use egui::{Event, Key, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -27,6 +26,30 @@ fn blank() -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formulas_ribbon_keeps_watch_window_inside_the_ribbon() {
|
||||
// Regression test for #360 (#342): Watch Window was a fourth small button in a column of
|
||||
// three, so it hung below the ribbon and was cut off.
|
||||
fn texts(shape: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| texts(s, out)),
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.galley.rect.translate(t.pos.to_vec2()))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().ui.ribbon_tab = "Formulas".into();
|
||||
h.run_steps(3);
|
||||
// Ribbon button labels are painted text: find them among the frame's shapes.
|
||||
let mut painted = Vec::new();
|
||||
h.output().shapes.iter().for_each(|c| texts(&c.shape, &mut painted));
|
||||
let bottom_of = |word: &str| painted.iter().filter(|(s, _)| s.contains(word)).map(|(_, r)| r.bottom()).fold(f32::NEG_INFINITY, f32::max);
|
||||
// "Calculation Options" is a big button whose second line sits at the foot of the ribbon.
|
||||
let (watch, foot) = (bottom_of("Window"), bottom_of("Options"));
|
||||
assert!(watch.is_finite() && foot.is_finite(), "the Formulas ribbon paints Watch Window and Calculation Options");
|
||||
assert!(watch <= foot + 0.5, "Watch Window's label ends at y={watch}, below the ribbon's last label row (y={foot})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_scrollbar_pages_and_drags_without_changing_selection() {
|
||||
let mut h = harness(blank());
|
||||
@@ -85,7 +108,7 @@ fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() {
|
||||
// Column A frozen: a page is the scrolling part only.
|
||||
sh.freeze = Some((0, 1));
|
||||
let mut s = Session::new();
|
||||
s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local));
|
||||
s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into()));
|
||||
let mut h = harness(s);
|
||||
let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar");
|
||||
assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}");
|
||||
@@ -401,7 +424,8 @@ fn renders_every_ribbon_tab_without_panicking() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_switch_renders_the_ribbon_and_persists() {
|
||||
fn language_switch_translates_the_ribbon_and_persists() {
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
let mut h = harness(blank());
|
||||
// English by default whatever the host's locale: only the desktop app reads the system language.
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
@@ -415,13 +439,31 @@ fn language_switch_renders_the_ribbon_and_persists() {
|
||||
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
|
||||
assert!(ids.contains(&id), "engine.commands lists {id}");
|
||||
}
|
||||
for language in Language::ALL {
|
||||
for (language, expected) in [
|
||||
(Language::Zh, "数据"),
|
||||
(Language::Ja, "データ"),
|
||||
(Language::Ko, "데이터"),
|
||||
(Language::Ru, "Данные"),
|
||||
(Language::PtBr, "Dados"),
|
||||
(Language::De, "Daten"),
|
||||
(Language::Fr, "Données"),
|
||||
(Language::Tr, "Veri"),
|
||||
(Language::It, "Dati"),
|
||||
] {
|
||||
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
|
||||
h.run_steps(2); // every locale renders the ribbon without panicking
|
||||
assert_eq!(h.state().ui.language, language);
|
||||
assert_eq!(language.tr("Data"), expected);
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, language);
|
||||
}
|
||||
// Czech spells "Data" as English does, so the Czech table is checked on a label that differs.
|
||||
h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.language, Language::Cs);
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::Cs);
|
||||
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
}
|
||||
@@ -434,21 +476,16 @@ fn pivot_fields_pane_follows_the_interface_language() {
|
||||
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap();
|
||||
h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
|
||||
h.run_steps(4);
|
||||
let es = |english| Language::Es.tr(english);
|
||||
for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english));
|
||||
}
|
||||
if es("PivotTable Fields") != "PivotTable Fields" {
|
||||
assert!(h.query_by_label("PivotTable Fields").is_none());
|
||||
for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] {
|
||||
assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`");
|
||||
}
|
||||
assert!(h.query_by_label("PivotTable Fields").is_none());
|
||||
h.get_by_label("Region ▾").click();
|
||||
h.run_steps(2);
|
||||
for english in ["Move to Column Labels", "Group by Years", "Remove Field"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english));
|
||||
}
|
||||
if es("Remove Field") != "Remove Field" {
|
||||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] {
|
||||
assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`");
|
||||
}
|
||||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -479,12 +516,9 @@ fn format_cells_draws_its_labels_in_the_interface_language() {
|
||||
h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap();
|
||||
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
|
||||
h.run_steps(2);
|
||||
let scientific = Language::De.tr("Scientific");
|
||||
assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated");
|
||||
if scientific != "Scientific" {
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
}
|
||||
h.get_by_label(scientific).click();
|
||||
assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated");
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
h.get_by_label("Wissenschaftlich").click();
|
||||
h.run_steps(2);
|
||||
let style = &h.state().dialog.as_ref().unwrap().values["style"];
|
||||
assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code");
|
||||
|
||||
@@ -316,10 +316,7 @@ fn keytip_hbd_() {
|
||||
fn keytip_hbe_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "HBE", false);
|
||||
assert!(h.state().dialog.is_none(), "HBE must not open a dialog");
|
||||
assert!(h.state().message.is_none(), "HBE must not open a message box");
|
||||
assert!(h.state().toast.is_none(), "HBE must not raise a toast");
|
||||
assert!(h.state().session.journal.is_empty(), "HBE must not journal");
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBE must journal home.borders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -340,10 +337,7 @@ fn keytip_hbh_() {
|
||||
fn keytip_hbi_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "HBI", false);
|
||||
assert!(h.state().dialog.is_none(), "HBI must not open a dialog");
|
||||
assert!(h.state().message.is_none(), "HBI must not open a message box");
|
||||
assert!(h.state().toast.is_none(), "HBI must not raise a toast");
|
||||
assert!(h.state().session.journal.is_empty(), "HBI must not journal");
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBI must journal home.borders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -357,7 +351,7 @@ fn keytip_hbl_() {
|
||||
fn keytip_hbm_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "HBM", false);
|
||||
assert!(h.state().dialog.is_some(), "HBM must open the formatCells dialog");
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBM must journal home.borders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,10 +414,7 @@ fn keytip_hbw_() {
|
||||
fn keytip_hby_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "HBY", false);
|
||||
assert!(h.state().dialog.is_none(), "HBY must not open a dialog");
|
||||
assert!(h.state().message.is_none(), "HBY must not open a message box");
|
||||
assert!(h.state().toast.is_none(), "HBY must not raise a toast");
|
||||
assert!(h.state().session.journal.is_empty(), "HBY must not journal");
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBY must journal home.borders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,14 +10,12 @@ mod autosave_persist;
|
||||
mod borders;
|
||||
mod cell_editor_caret;
|
||||
mod cell_pictures;
|
||||
mod cf_rule_pick;
|
||||
mod chart_resize;
|
||||
mod clipboard;
|
||||
mod comments_pane;
|
||||
mod commit_after_pointing;
|
||||
mod committed_text_fields;
|
||||
mod context_menu_stays_open;
|
||||
mod contextual_ribbon_tab;
|
||||
mod control_timeout;
|
||||
mod dialog_button_row;
|
||||
mod dialog_confirm;
|
||||
@@ -61,7 +59,6 @@ mod page_layout_view;
|
||||
mod page_setup_margins;
|
||||
mod paste_special_dialog;
|
||||
mod pivot_pane;
|
||||
mod pivot_show_details;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod right_to_left;
|
||||
@@ -86,7 +83,6 @@ mod touch_pan;
|
||||
mod trace_navigation;
|
||||
mod translated_form_fields;
|
||||
mod ui_clock;
|
||||
mod undo_while_grouping;
|
||||
mod validation_alert;
|
||||
mod validation_input_message;
|
||||
mod wheel_zoom;
|
||||
|
||||
@@ -56,3 +56,47 @@ fn pivot_field_button_surfaces_follow_the_active_theme() {
|
||||
assert_eq!(field_button_fill(&shapes, "Region ▾"), expected, "dark mode: {dark}");
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(shape: Shape, out: &mut Vec<Shape>) {
|
||||
match shape {
|
||||
Shape::Vec(shapes) => shapes.into_iter().for_each(|s| flatten(s, out)),
|
||||
s => out.push(s),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_field_list_scrollbar_sits_at_the_pane_edge() {
|
||||
// Regression test for #1485: with enough source fields to scroll, the field list's scrollbar
|
||||
// sat just right of the longest (short) field name instead of at the pane's right edge.
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
let names: Vec<String> = (1..=14).map(|i| format!("F{i}")).collect();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [names.clone(), (1..=14).collect::<Vec<i32>>()]})).unwrap();
|
||||
session.execute("insert.pivotTable", json!({"source": "Sheet1!A1:N2", "destination": "Sheet1!A5", "rows": ["F1"], "values": ["F2"]})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A5"})).unwrap();
|
||||
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);
|
||||
let first = h.get_by_label("F1").rect();
|
||||
// A floating scrollbar is drawn while the pointer is over its area.
|
||||
h.get_by_label("F1").hover();
|
||||
h.run_steps(3);
|
||||
let mut shapes = Vec::new();
|
||||
h.output().shapes.iter().for_each(|c| flatten(c.shape.clone(), &mut shapes));
|
||||
let bar = shapes
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::Rect(r) if r.fill.a() > 0 && r.rect.width() <= 12.0 && r.rect.height() >= 20.0 && r.rect.left() > first.left() => Some(r.rect),
|
||||
_ => None,
|
||||
})
|
||||
.max_by(|a, b| a.right().total_cmp(&b.right()))
|
||||
.expect("the field list draws a scrollbar");
|
||||
assert!(bar.right() > 1200.0 - 20.0, "the scrollbar {bar:?} sits at the pane's right edge, not beside the field names ({first:?})");
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's
|
||||
//! Show Details); a label of the report still opens the cell for editing.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap();
|
||||
// Report at D3: D4 East, E4 its Sum of Sales.
|
||||
session
|
||||
.execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]}))
|
||||
.unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 {
|
||||
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);
|
||||
geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center()
|
||||
}
|
||||
|
||||
fn double_click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) {
|
||||
for _ in 0..2 {
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_value_cell_shows_its_records_on_a_new_sheet() {
|
||||
let mut h = harness();
|
||||
let pos = center(&h, "E4");
|
||||
double_click(&mut h, pos);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.editor.is_none(), "the value cell must not open for editing");
|
||||
let wb = &app.session.active().unwrap().wb;
|
||||
assert_eq!(wb.sheets.len(), 2);
|
||||
let sheet = wb.active().unwrap();
|
||||
assert_eq!(sheet.name, "Sheet2");
|
||||
let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display();
|
||||
assert_eq!(
|
||||
[text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")],
|
||||
["Region", "Sales", "East", "100", "East", "30", ""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_row_label_still_edits() {
|
||||
let mut h = harness();
|
||||
let pos = center(&h, "D4");
|
||||
double_click(&mut h, pos);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.editor.is_some());
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_value_cell_of_a_protected_workbook_says_why_and_does_not_edit() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("review.protectWorkbook", json!({"on": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
let pos = center(&h, "E4");
|
||||
double_click(&mut h, pos);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.editor.is_none(), "the value cell must not open for editing");
|
||||
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("protected")), "{:?}", app.message);
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_in_an_out_of_date_report_asks_for_a_refresh() {
|
||||
let mut h = harness();
|
||||
// West goes away without a refresh: E6, the Grand Total on the sheet, has no cell in the
|
||||
// report the source now gives.
|
||||
h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let pos = center(&h, "E6");
|
||||
double_click(&mut h, pos);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.editor.is_none(), "the cell must not open for editing");
|
||||
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message);
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ fn harness(freeze: Option<(u32, u32)>) -> Harness {
|
||||
sh.view_active = CellRef::parse("A31").unwrap();
|
||||
sh.freeze = freeze;
|
||||
let mut session = Session::new();
|
||||
session.add_document(DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local));
|
||||
session.add_document(DocState::new(wb, None, "Book1".into()));
|
||||
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());
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
@@ -24,17 +23,19 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
|
||||
#[test]
|
||||
fn form_fields_are_translated() {
|
||||
for (dialog, labels) in [("pageSetup", ["Left margin (in):", "Footer (in):"]), ("insertPaste", ["Shift cells right", "Shift cells down"])] {
|
||||
for (dialog, translated, english) in [
|
||||
("pageSetup", ["Margem esquerda (pol.):", "Rodapé (pol.):"], ["Left margin (in):", "Footer (in):"]),
|
||||
("insertPaste", ["Deslocar células para a direita", "Deslocar células para baixo"], ["Shift cells right", "Shift cells down"]),
|
||||
] {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap();
|
||||
h.state_mut().open_dialog(dialog, json!({}));
|
||||
h.run_steps(3);
|
||||
for english in labels {
|
||||
let translated = Language::PtBr.tr(english);
|
||||
assert!(h.query_by_label_contains(translated).is_some(), "{dialog}: no {translated}");
|
||||
if translated != english {
|
||||
assert!(h.query_by_label_contains(english).is_none(), "{dialog}: {english} is untranslated");
|
||||
}
|
||||
for label in translated {
|
||||
assert!(h.query_by_label_contains(label).is_some(), "{dialog}: no {label}");
|
||||
}
|
||||
for label in english {
|
||||
assert!(h.query_by_label_contains(label).is_none(), "{dialog}: {label} is untranslated");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Undo and Redo on the Quick Access Toolbar follow the commands' own availability: while an undo
|
||||
//! group is open on the document (an action that spans several calls is still running), both
|
||||
//! buttons are off, and a click neither runs the command nor raises its refusal.
|
||||
|
||||
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
s.execute("edit.undo", json!({})).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
|
||||
}
|
||||
|
||||
fn history(h: &Harness) -> (usize, usize) {
|
||||
let d = h.state().session.active().unwrap();
|
||||
(d.undo.len(), d.redo.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_wait_while_an_undo_group_is_open() {
|
||||
let mut h = harness();
|
||||
assert_eq!(history(&h), (1, 1));
|
||||
let token = h.state_mut().session.group_begin("Long action").unwrap();
|
||||
h.run_steps(2);
|
||||
for button in ["Undo", "Redo"] {
|
||||
assert!(h.get_by_label(button).accesskit_node().is_disabled(), "{button} is off while the group is open");
|
||||
h.get_by_label(button).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().message.is_none(), "{button}: no refusal is raised while the group is open");
|
||||
assert_eq!(history(&h), (1, 1), "{button}: the history is as it was");
|
||||
}
|
||||
h.state_mut().session.group_end(token).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(!h.get_by_label("Undo").accesskit_node().is_disabled() && !h.get_by_label("Redo").accesskit_node().is_disabled());
|
||||
// With the group ended, the buttons work again.
|
||||
h.get_by_label("Undo").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(history(&h), (0, 2));
|
||||
h.get_by_label("Redo").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(history(&h), (1, 1));
|
||||
}
|
||||
@@ -680,96 +680,3 @@ fn font_dropdown_highlight_follows_the_filtered_rows() {
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_label("Georgia").count() >= 1, "an emptied filter lists every font again");
|
||||
}
|
||||
|
||||
fn borders(h: &Harness, cell: &str) -> gridcraft_engine::model::Borders {
|
||||
let d = h.state().session.active().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).borders
|
||||
}
|
||||
|
||||
/// [left, right, top, bottom] line styles of `cell`.
|
||||
fn sides(h: &Harness, cell: &str) -> [gridcraft_engine::model::BorderStyle; 4] {
|
||||
let b = borders(h, cell);
|
||||
[b.left.style, b.right.style, b.top.style, b.bottom.style]
|
||||
}
|
||||
|
||||
fn selected(range: &str) -> Session {
|
||||
let mut s = blank();
|
||||
s.execute("selection.set", json!({"range": range})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_keytips_apply_their_own_preset() {
|
||||
use gridcraft_engine::model::BorderStyle::{Double, None as N, Thick, Thin};
|
||||
// Alt, H, B, letter on A1 alone: each Borders menu letter draws its own preset (they all drew
|
||||
// a bare bottom border before). Letters observed in Excel's Borders menu.
|
||||
for (k, want) in [
|
||||
(Key::O, [N, N, N, Thin]),
|
||||
(Key::P, [N, N, Thin, N]),
|
||||
(Key::L, [Thin, N, N, N]),
|
||||
(Key::R, [N, Thin, N, N]),
|
||||
(Key::A, [Thin, Thin, Thin, Thin]),
|
||||
(Key::S, [Thin, Thin, Thin, Thin]),
|
||||
(Key::T, [Thick, Thick, Thick, Thick]),
|
||||
(Key::B, [N, N, N, Double]),
|
||||
(Key::H, [N, N, N, Thick]),
|
||||
(Key::D, [N, N, Thin, Thin]),
|
||||
(Key::C, [N, N, Thin, Thick]),
|
||||
(Key::U, [N, N, Thin, Double]),
|
||||
] {
|
||||
let mut h = harness(selected("A1"));
|
||||
sequence(&mut h, &[Key::H, Key::B, k], false);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "H,B,{k:?} must run cleanly");
|
||||
assert_eq!(sides(&h, "A1"), want, "H,B,{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_keytip_no_border_clears_a_bordered_cell() {
|
||||
use gridcraft_engine::model::BorderStyle::None as N;
|
||||
let mut s = selected("A1");
|
||||
s.execute("home.borders", json!({"range": "A1", "preset": "all"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
sequence(&mut h, &[Key::H, Key::B, Key::N], false);
|
||||
assert_eq!(sides(&h, "A1"), [N, N, N, N], "H,B,N must clear every side, not add a bottom border");
|
||||
assert_eq!(h.state().grid.last_border, "none", "the picked preset becomes the Borders button's face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_keytip_all_borders_covers_every_side_of_a_block() {
|
||||
use gridcraft_engine::model::BorderStyle::Thin;
|
||||
let mut h = harness(selected("A1:B2"));
|
||||
sequence(&mut h, &[Key::H, Key::B, Key::A], false);
|
||||
for cell in ["A1", "B1", "A2", "B2"] {
|
||||
assert_eq!(sides(&h, cell), [Thin; 4], "H,B,A must border every side of {cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_keytip_outside_borders_skips_the_inside_edges() {
|
||||
use gridcraft_engine::model::BorderStyle::{None as N, Thin};
|
||||
let mut h = harness(selected("A1:B2"));
|
||||
sequence(&mut h, &[Key::H, Key::B, Key::S], false);
|
||||
assert_eq!(sides(&h, "A1"), [Thin, N, Thin, N]);
|
||||
assert_eq!(sides(&h, "B2"), [N, Thin, N, Thin]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_keytip_more_borders_opens_format_cells_on_the_border_tab() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::B, Key::M], false);
|
||||
let d = h.state().dialog.as_ref().expect("H,B,M opens Format Cells");
|
||||
assert_eq!(d.tab, "Border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_keytips_without_a_command_stay_silent() {
|
||||
// Erase Border (a pen mode), Line Color and Line Style are reserved: no border is drawn.
|
||||
for k in [Key::E, Key::I, Key::Y] {
|
||||
let mut h = harness(selected("A1"));
|
||||
sequence(&mut h, &[Key::H, Key::B, k], false);
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.")), "H,B,{k:?} must not run a command");
|
||||
assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,10 @@ pub fn builtin_format(id: u32) -> Option<&'static str> {
|
||||
BUILTIN_FORMATS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
/// Id to write for a format code when it is a locale-independent built-in. The currency formats
|
||||
/// 5–8 and the accounting formats with a currency, 42 and 44, are written as custom formats because
|
||||
/// their meaning depends on the reader's locale.
|
||||
/// Id to write for a format code when it is a locale-independent built-in (currency formats 5–8
|
||||
/// are written as custom formats because their meaning depends on the reader's locale).
|
||||
pub fn builtin_id(code: &str) -> Option<u32> {
|
||||
BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !matches!(i, 5..=8 | 42 | 44)).map(|(i, _)| *i)
|
||||
BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !(5..=8).contains(i)).map(|(i, _)| *i)
|
||||
}
|
||||
|
||||
pub use gridcraft_core::color::INDEXED;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//! Number formats whose meaning depends on the reader's locale are written with their code: the
|
||||
//! currency formats 5–8 and the accounting formats with a currency, 42 and 44. Written as a bare
|
||||
//! built-in id, LibreOffice and Excel in other locales show their own version (a 44 cell holding
|
||||
//! -0.14 reads `$ (0.14)` in US Excel but `- 0.14` in LibreOffice).
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::tables::builtin_format;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
fn styles_xml(bytes: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Writes one cell formatted with `code`; returns the `numFmtId` its cell format points at, the
|
||||
/// `styles.xml` and the code read back.
|
||||
fn round_trip(code: &str) -> (u32, String, String) {
|
||||
let mut wb = Workbook::new();
|
||||
let style = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code));
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
sheet.cells.set(CellRef::new(0, 0), Cell { value: Value::Number(-0.14), formula: None, style });
|
||||
wb.sheets = vec![Arc::new(sheet)];
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let xml = styles_xml(&bytes);
|
||||
let xfs = &xml[xml.find("<cellXfs").unwrap()..];
|
||||
let id = xfs
|
||||
.match_indices("numFmtId=\"")
|
||||
.map(|(i, m)| xfs[i + m.len()..].split('"').next().unwrap().parse::<u32>().unwrap())
|
||||
.find(|&id| id != 0)
|
||||
.unwrap();
|
||||
let (back, _) = read_xlsx(&bytes).unwrap();
|
||||
let st = back.styles.get(back.sheet(0).unwrap().cell(CellRef::new(0, 0)).unwrap().style);
|
||||
(id, xml, st.num_fmt.as_str().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_and_accounting_formats_are_written_with_their_code() {
|
||||
for builtin in [5, 6, 7, 8, 42, 44] {
|
||||
let code = builtin_format(builtin).unwrap();
|
||||
let (id, xml, back) = round_trip(code);
|
||||
assert!(id >= 164, "format {builtin} written as built-in id {id}");
|
||||
assert!(xml.contains(&format!("<numFmt numFmtId=\"{id}\"")), "format {builtin}: no <numFmt> for id {id}");
|
||||
assert_eq!(back, code, "format {builtin}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_independent_built_ins_keep_their_id() {
|
||||
for builtin in [3, 4, 10, 14, 41, 43, 49] {
|
||||
let (id, _, back) = round_trip(builtin_format(builtin).unwrap());
|
||||
assert_eq!(id, builtin);
|
||||
assert_eq!(back, builtin_format(builtin).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ mod encrypted_tests;
|
||||
mod fixtures;
|
||||
mod ink_roundtrip;
|
||||
mod literal_category_roundtrip;
|
||||
mod locale_numfmt;
|
||||
mod malformed;
|
||||
mod multilevel_category_roundtrip;
|
||||
mod note_box;
|
||||
|
||||
+2
-49
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (bound Undo's retained cell storage) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
|
||||
|
||||
@@ -33,10 +33,7 @@ including tests, 2026-10-10.
|
||||
`Session::execute(id, params)`. Commands are `CommandSpec { id, label, menu, shortcut, params,
|
||||
enabled, run, journal, undoable }`; ids follow Excel's ribbon (`home.bold`, `insert.chart`,
|
||||
`data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`).
|
||||
`Session::execute` catches escaped panics and restores the workbook, its selection and its
|
||||
undo and redo history as they were before the command (never-crash guard); a group that was
|
||||
open before the command and that the command ended or rolled back stays ended and keeps a
|
||||
step from its start to the restored workbook, so Undo still returns to the state before it.
|
||||
`Session::execute` catches escaped panics and restores the workbook (never-crash guard).
|
||||
2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot.
|
||||
Undo retains at most 100 steps and trims older steps when its estimated retained cell storage
|
||||
exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget.
|
||||
@@ -49,49 +46,6 @@ including tests, 2026-10-10.
|
||||
5. **Files**: `engine/io.rs` sniffs content (XLSX, XLSB, ODS) before trusting the extension and
|
||||
dispatches to `crates/xlsx`; imports that lose data return warnings shown to the user.
|
||||
|
||||
## Engine building blocks for multi-command actions
|
||||
|
||||
Actions made of many commands (macros, a debugger that runs one step per call) use these parts of
|
||||
`Session` and the engine:
|
||||
|
||||
- **Undo groups.** `Session::grouped(label, f)` runs `f` as one undo step and rolls every change
|
||||
back if `f` fails; `group_begin`, `group_end` and `group_rollback` do the same across calls.
|
||||
While a group is open on a document, its commands add no undo steps of their own and Undo and
|
||||
Redo are disabled there. Groups nest; the outermost one makes the step. A command that opens and
|
||||
closes its own groups still makes one undo step for itself, covering its edits inside and outside
|
||||
the groups. A command that ends a group open before it leaves the group its step and makes one
|
||||
of its own for what it changes after the end; a group a command opens and leaves open starts
|
||||
where the command did, but only when the command succeeds: a command that fails rolls back
|
||||
every group it opened, as `group_rollback` does. `Session::abort_groups(doc)`, and the command
|
||||
`automate.stopAction` on the active workbook, roll back the groups left open on a document (a
|
||||
debugger the person abandons, a caller that dropped its token), so Undo works there again. The
|
||||
history limit is applied when the outermost command finishes; until then the steps made inside
|
||||
it are bounded on their own.
|
||||
- **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful
|
||||
top-level command with the document, sheet and selection it ran in, without secret parameters.
|
||||
Commands run by other commands are not recorded, nor is a menu-style call that only opened a
|
||||
dialog (the dialog's confirming call is the step), and the buffer is independent of the journal
|
||||
(Automate › Record Actions still records through the journal).
|
||||
- **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and
|
||||
`FromFile` for workbooks opened from a file. `Trust` has no default: `DocState::new` takes it,
|
||||
so every way of making a document says where its content came from. It is also `FromFile` for
|
||||
workbooks that received a paste from one that writes formulas or validation: Paste, Paste
|
||||
Special › All, All Except Borders, Formulas, Formulas and Number Formats or Validation, and any
|
||||
linked paste other than column widths or notes. Values, formats, column widths and notes carry
|
||||
nothing active. Active content in a `FromFile` workbook asks before it runs. This is provenance,
|
||||
which no decision changes; `DocState::scripts_trusted` is the separate per-session consent for
|
||||
stored `automate.*` scripts (`automate.trustScripts`).
|
||||
- **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden
|
||||
workbook-scope names `_gc_<key>_0`, `_gc_<key>_1`, … of string literals within Excel's 255- and
|
||||
8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls
|
||||
other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter
|
||||
U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items`
|
||||
and `stash::keys` list the items under a key prefix in one pass over the names, at most
|
||||
10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_<key>` items are read as well.
|
||||
`formulas.defineName` and `formulas.deleteName` refuse a name the workbook holds such an item
|
||||
under (a hidden workbook-scope name of either shape; a visible one is the person's own), and
|
||||
`formulas.createFromSelection` skips such labels, unless `force: true` is passed.
|
||||
|
||||
## Agent control
|
||||
|
||||
- Desktop control channel: `gridcraft --control 7979`, JSON lines on localhost
|
||||
@@ -117,6 +71,5 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage |
|
||||
| 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee |
|
||||
| 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** minor (number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-21 · **Change:** minor (SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
|
||||
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
|
||||
@@ -85,7 +85,6 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
| SUBTOTAL / AGGREGATE input size | references trim to the used range; all areas, arrays and values of one call are charged against the 16-million-cell limit before any cell is read (skipped hidden, nested and error cells count), and `#NUM!` past it (#2175) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |
|
||||
| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` |
|
||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
|
||||
@@ -123,8 +122,6 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 |
|
||||
| 2026-10-23 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns |
|
||||
| 2026-10-21 | minor | SUBTOTAL and AGGREGATE charge every argument against the evaluator's 16-million-cell limit before scanning; a value in XFD1048576 no longer makes `SUBTOTAL(9,A:XFD)` read the whole sheet (#2175) |
|
||||
| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) |
|
||||
| 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands |
|
||||
@@ -132,8 +129,6 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
| 2026-10-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) |
|
||||
| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation |
|
||||
| 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) |
|
||||
| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed |
|
||||
| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it |
|
||||
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
|
||||
| 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) |
|
||||
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
|
||||
|
||||
+2
-4
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
@@ -84,8 +84,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F)
|
||||
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
|
||||
`insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields
|
||||
and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists
|
||||
its source records on a new sheet; the "Enable show details" option isn't modelled yet.
|
||||
and values on rows done).
|
||||
- **Impact:** pivots are the analyst's main tool.
|
||||
- **Estimate:** 20–30 h for beta (25–40 h full).
|
||||
|
||||
@@ -179,7 +178,6 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) |
|
||||
| 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step |
|
||||
| 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite |
|
||||
| 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus; Dutch interface language; Romanian interface catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
|
||||
packs cover 100+), with localized function names, argument separators, number and date formats,
|
||||
@@ -96,9 +96,8 @@ language (none so far) and of localized function names.
|
||||
## Infrastructure (do first)
|
||||
|
||||
1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes,
|
||||
status and error messages, command labels, function descriptions). A string a catalog lacks
|
||||
shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings
|
||||
per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31).
|
||||
status and error messages, command labels, function descriptions), with a missing-key check
|
||||
in `cargo xtask ci`. Language switching and the system-language default already exist (#31).
|
||||
2. Locale-aware formulas: localized function-name tables and separators at the formula-bar
|
||||
boundary only (files keep en-US invariant names), locale number and date formats, list
|
||||
separators in CSV.
|
||||
@@ -110,7 +109,6 @@ language (none so far) and of localized function names.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay |
|
||||
| 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review |
|
||||
| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |
|
||||
| 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) |
|
||||
|
||||
+2
-7
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
@@ -86,7 +86,7 @@ this logic, with one undo step.
|
||||
| Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done |
|
||||
| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing |
|
||||
| Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) |
|
||||
| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; each Borders letter (H, B, …) draws its own preset, H, B, M opens Format Cells on the Border tab, and Erase Border, Line Color and Line Style are reserved; QAT/Backstage excluded |
|
||||
| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; QAT/Backstage excluded |
|
||||
| Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) |
|
||||
|
||||
### Shortcut audit (#110)
|
||||
@@ -152,9 +152,6 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing
|
||||
Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
|
||||
Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its
|
||||
title bar, keeping its place while it is open (#1250).
|
||||
New Formatting Rule's formula box points like the formula bar: a click on the grid writes the
|
||||
cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule
|
||||
applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet.
|
||||
|
||||
## Accessibility
|
||||
|
||||
@@ -178,7 +175,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
|
||||
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |
|
||||
| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |
|
||||
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |
|
||||
@@ -203,7 +199,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
|
||||
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
|
||||
| 2026-10-14 | minor | PivotTable source-field list fills the pane width so its scrollbar stays at the list's right edge |
|
||||
| 2026-10-14 | minor | Alt, H, B border keytips apply the preset each letter names in Excel's Borders menu (they all drew a bottom border, so H,B,N No Border added one): keytips.json entries take optional `params`; H,B,M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style stay reserved |
|
||||
| 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) |
|
||||
| 2026-10-13 | trivial | More Colors… and Recent Colors (#928) |
|
||||
| 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | i18n-report | version | stats | ico"
|
||||
description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | version | stats | ico"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
//! `cargo xtask i18n-report`: the interface strings each language doesn't translate yet, read from
|
||||
//! the catalogs in `crates/ui-egui/src/i18n/`. A string a catalog lacks shows in English, so the
|
||||
//! report always succeeds; `cargo xtask ci` runs it first so the list shows in the gate log.
|
||||
//!
|
||||
//! The reference set is every key any catalog translates. A catalog is a source file with a
|
||||
//! `fn translate`; its keys are the string literals that start a match arm (`"key" => …`) or open a
|
||||
//! table row (`("key", …)`). With the `engine` feature it also lists function help rows keyed by
|
||||
//! English the function catalog no longer has: those show the English help.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
/// Missing keys listed per language; the rest are counted.
|
||||
const LIST_CAP: usize = 50;
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let dir = root.join("crates/ui-egui/src/i18n");
|
||||
let mut catalogs = BTreeMap::new();
|
||||
let mut help = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
println!("i18n-report: can't read {}: {e}", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let Ok(source) = std::fs::read_to_string(&path) else { continue };
|
||||
if let Some(language) = stem.strip_suffix("_functions") {
|
||||
help.insert(language.to_owned(), catalog_keys(&source));
|
||||
} else if source.contains("fn translate(") {
|
||||
catalogs.insert(stem, catalog_keys(&source));
|
||||
}
|
||||
}
|
||||
print!("{}", render(&catalogs));
|
||||
print!("{}", function_help(&help));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per language: how many reference keys it lacks, then the first [`LIST_CAP`] of them.
|
||||
pub fn render(catalogs: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
let reference: BTreeSet<&String> = catalogs.values().flatten().collect();
|
||||
let mut out =
|
||||
format!("i18n-report: {} interface strings across {} catalogs; a string a catalog lacks shows in English\n", reference.len(), catalogs.len());
|
||||
for (language, keys) in catalogs {
|
||||
let missing: Vec<&&String> = reference.iter().filter(|key| !keys.contains(key.as_str())).collect();
|
||||
let _ = writeln!(out, " {language}: {} missing", missing.len());
|
||||
list(&mut out, missing.iter().map(|key| key.as_str()), missing.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn list<'a>(out: &mut String, keys: impl Iterator<Item = &'a str>, count: usize) {
|
||||
for key in keys.take(LIST_CAP) {
|
||||
let _ = writeln!(out, " {key:?}");
|
||||
}
|
||||
if count > LIST_CAP {
|
||||
let _ = writeln!(out, " … and {} more", count - LIST_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// Function help rows whose English the function catalog no longer has (an English description
|
||||
/// reworded, an argument renamed).
|
||||
#[cfg(feature = "engine")]
|
||||
fn function_help(help: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
let catalog = gridcraft_engine::cmd::formulas::function_list();
|
||||
let mut known: BTreeSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
|
||||
known.extend(catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(argument_names));
|
||||
let mut out = String::new();
|
||||
for (language, keys) in help {
|
||||
let stale: Vec<&str> = keys.iter().map(String::as_str).filter(|key| !known.contains(key)).collect();
|
||||
let _ = writeln!(out, " {language} function help: {} rows, {} keyed by English the function catalog no longer has", keys.len(), stale.len());
|
||||
list(&mut out, stale.iter().copied(), stale.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "engine"))]
|
||||
fn function_help(_: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// `SUM(number1, [number2], ...)` -> `number`, `number`: brackets, index and `...` dropped.
|
||||
#[cfg(feature = "engine")]
|
||||
fn argument_names(signature: &str) -> Vec<&str> {
|
||||
let Some(inner) = signature.split_once('(').and_then(|(_, rest)| rest.strip_suffix(')')) else { return Vec::new() };
|
||||
inner
|
||||
.split(", ")
|
||||
.map(|arg| {
|
||||
let arg = arg.strip_prefix('[').unwrap_or(arg);
|
||||
let arg = arg.strip_suffix(']').unwrap_or(arg);
|
||||
arg.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
})
|
||||
.filter(|name| !name.is_empty() && *name != "...")
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The keys of one catalog source: string literals that start a match arm or open a table row.
|
||||
/// Comments and the test module are skipped.
|
||||
pub fn catalog_keys(source: &str) -> BTreeSet<String> {
|
||||
let source = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
let mut keys = BTreeSet::new();
|
||||
for (value, start, end) in literals(source) {
|
||||
let before = source.get(..start).unwrap_or("").trim_end();
|
||||
let after = source.get(end..).unwrap_or("").trim_start();
|
||||
if after.starts_with("=>") || (before.ends_with('(') && after.starts_with(',')) {
|
||||
keys.insert(value);
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Every string literal outside `//` comments: its decoded value, and the byte range from its
|
||||
/// opening quote to just past its closing one.
|
||||
fn literals(source: &str) -> Vec<(String, usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = source.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c == '/' && source.get(i..).is_some_and(|rest| rest.starts_with("//")) {
|
||||
for (_, c) in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c != '"' {
|
||||
continue;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut end = source.len();
|
||||
while let Some((j, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
'\\' => 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(c @ ('\\' | '"' | '\'')) => value.push(c),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for (_, c) in chars.by_ref() {
|
||||
match c {
|
||||
'}' => break,
|
||||
'{' => {}
|
||||
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 skips the newline and the next line's indentation.
|
||||
Some('\n') => {
|
||||
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
c => value.push(c),
|
||||
}
|
||||
}
|
||||
out.push((value, i, end));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_match_arms_and_table_rows_and_lists_what_each_catalog_lacks() {
|
||||
let matched = r#"
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Einfügen", // "Commented" => "x",
|
||||
"Line\nbreak \"quoted\"" => {
|
||||
"Zeile"
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests { const T: &[(&str, &str)] = &[("Test only", "x")]; }
|
||||
"#;
|
||||
let table = r#"
|
||||
pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Paste", "貼り付け"),
|
||||
(
|
||||
"Long \u{2026}",
|
||||
"長い",
|
||||
),
|
||||
];
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> { None }
|
||||
"#;
|
||||
let de = catalog_keys(matched);
|
||||
assert_eq!(de.iter().map(String::as_str).collect::<Vec<_>>(), ["Line\nbreak \"quoted\"", "Paste"]);
|
||||
let ja = catalog_keys(table);
|
||||
assert_eq!(ja.iter().map(String::as_str).collect::<Vec<_>>(), ["Long …", "Paste"]);
|
||||
|
||||
let report = render(&BTreeMap::from([("de".to_owned(), de), ("ja".to_owned(), ja)]));
|
||||
assert!(report.starts_with("i18n-report: 3 interface strings across 2 catalogs"), "{report}");
|
||||
assert!(report.contains(" de: 1 missing\n \"Long …\"\n"), "{report}");
|
||||
assert!(report.contains(" ja: 1 missing\n \"Line\\nbreak \\\"quoted\\\"\"\n"), "{report}");
|
||||
}
|
||||
}
|
||||
+9
-30
@@ -49,35 +49,25 @@ struct Entry {
|
||||
kind: String,
|
||||
target: String,
|
||||
pending: bool,
|
||||
/// Fixed params as a compact JSON object (`params` in keytips.json), empty for none.
|
||||
params: String,
|
||||
excel: String,
|
||||
note: String,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The probe key: a command and the params its keytip passes.
|
||||
fn probe_key(&self) -> String {
|
||||
if self.params.is_empty() || self.params == "{}" { self.target.clone() } else { format!("{} {}", self.target, self.params) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 = ran and journaled, 1 = ran without a journal step, 2 = errored (params
|
||||
/// missing, disabled, or environment-dependent). Keyed by [`Entry::probe_key`].
|
||||
/// missing, disabled, or environment-dependent).
|
||||
type Probe = std::collections::HashMap<String, u8>;
|
||||
|
||||
#[cfg(feature = "engine")]
|
||||
fn probe_live_commands(calls: &[(String, String)]) -> Probe {
|
||||
fn probe_live_commands(ids: &[String]) -> Probe {
|
||||
use gridcraft_engine::Session;
|
||||
let mut map = Probe::new();
|
||||
for (id, params) in calls {
|
||||
for id in ids {
|
||||
if ENV_SENSITIVE.contains(&id.as_str()) || UI_DIALOG_COMMANDS.iter().any(|(c, _)| c == id) {
|
||||
continue;
|
||||
}
|
||||
let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() };
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let status = match s.execute(id, params.clone()) {
|
||||
let status = match s.execute(id, serde_json::json!({})) {
|
||||
Ok(_) => {
|
||||
if s.journal.iter().any(|(jid, _)| jid == id) {
|
||||
0
|
||||
@@ -87,8 +77,7 @@ fn probe_live_commands(calls: &[(String, String)]) -> Probe {
|
||||
}
|
||||
Err(_) => 2,
|
||||
};
|
||||
let key = if params.as_object().is_some_and(|o| !o.is_empty()) { format!("{id} {params}") } else { id.clone() };
|
||||
map.insert(key, status);
|
||||
map.insert(id.clone(), status);
|
||||
}
|
||||
map
|
||||
}
|
||||
@@ -148,13 +137,6 @@ fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
|
||||
if pending && kind != "command" {
|
||||
return Err(format!("{at}: `pending` is only valid with kind `command`"));
|
||||
}
|
||||
let params = match e.get("params") {
|
||||
None => String::new(),
|
||||
Some(v) if v.is_object() && matches!(kind.as_str(), "command" | "dialog") && !pending => {
|
||||
serde_json::to_string(v).map_err(|e| format!("{at}: params: {e}"))?
|
||||
}
|
||||
Some(_) => return Err(format!("{at}: `params` must be a JSON object, on a non-pending command or dialog")),
|
||||
};
|
||||
match kind.as_str() {
|
||||
"tab" => {
|
||||
if target.is_empty() || !TABS.contains(&target.as_str()) {
|
||||
@@ -186,7 +168,6 @@ fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
|
||||
kind,
|
||||
target,
|
||||
pending,
|
||||
params,
|
||||
excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
});
|
||||
@@ -194,10 +175,10 @@ fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
|
||||
// JSON order is authoritative and curated: the first entry for a tab is its canonical
|
||||
// badge (legacy aliases come later), and groups stay together. Do not sort.
|
||||
|
||||
let live_calls: Vec<(String, String)> = entries
|
||||
let live_ids: Vec<String> = entries
|
||||
.iter()
|
||||
.filter(|e| e.kind == "command" && !e.pending)
|
||||
.map(|e| (e.target.clone(), e.params.clone()))
|
||||
.map(|e| e.target.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -211,7 +192,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
|
||||
e.target.clone()
|
||||
})
|
||||
.collect();
|
||||
let mut probe = probe_live_commands(&live_calls);
|
||||
let mut probe = probe_live_commands(&live_ids);
|
||||
for id in landed {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
@@ -243,9 +224,7 @@ fn render_table(entries: &[Entry]) -> String {
|
||||
let value = match e.kind.as_str() {
|
||||
"tab" => format!("Entry::Tab({:?})", e.target),
|
||||
"command" if e.pending => format!("Entry::Finish(Action::Pending({:?}))", e.target),
|
||||
"command" if !e.params.is_empty() => format!("Entry::Finish(Action::CommandWith({:?}, {:?}))", e.target, e.params),
|
||||
"command" => format!("Entry::Finish(Action::Command({:?}))", e.target),
|
||||
"dialog" if !e.params.is_empty() => format!("Entry::Finish(Action::DialogWith({:?}, {:?}))", e.target, e.params),
|
||||
"dialog" => format!("Entry::Finish(Action::Dialog({:?}))", e.target),
|
||||
"control" => format!("Entry::Finish(Action::Control({:?}))", e.target),
|
||||
"continue" => "Entry::Continue".to_string(),
|
||||
@@ -306,7 +285,7 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String {
|
||||
seq = e.seq,
|
||||
)
|
||||
}
|
||||
"command" => match probe.get(&e.probe_key()).copied().unwrap_or(2) {
|
||||
"command" => match probe.get(&e.target).copied().unwrap_or(2) {
|
||||
0 => format!(
|
||||
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().session.journal.iter().any(|(id, _)| id == {id:?}), \"{seq} must journal {id}\");",
|
||||
seq = e.seq,
|
||||
|
||||
+3
-8
@@ -6,7 +6,6 @@
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
mod assets;
|
||||
mod i18n_report;
|
||||
mod ico;
|
||||
mod keytips;
|
||||
mod layers;
|
||||
@@ -22,12 +21,11 @@ const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
ci i18n-report, fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm,
|
||||
parity --check, keytips --check (stops at the first failure; the parity check runs
|
||||
only if docs/parity-checklist.md exists)
|
||||
ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check,
|
||||
keytips --check (stops at the first failure; the parity check runs only if
|
||||
docs/parity-checklist.md exists)
|
||||
test workspace tests (cargo nextest when installed, else cargo test), then doctests
|
||||
assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md
|
||||
i18n-report list the interface strings each language doesn't translate yet (always passes)
|
||||
layers enforce the crate dependency layering (AGENTS.md \"Layering\")
|
||||
wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call
|
||||
parity [--check] [--out PATH]
|
||||
@@ -46,7 +44,6 @@ fn main() -> ExitCode {
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("i18n-report") => i18n_report::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
|
||||
@@ -197,8 +194,6 @@ fn cmd_doctest() -> Result<(), String> {
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
// Missing translations never fail the gate; the list goes first so every log shows it.
|
||||
("i18n-report", Box::new(|| i18n_report::run(&root()))),
|
||||
(
|
||||
"fmt",
|
||||
Box::new(|| {
|
||||
|
||||
Reference in New Issue
Block a user