Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 9664c01289 xtask: probe keytip commands without file access, and refuse text that could escape generated code
cargo xtask keytips ran every command keytips.json names, with its
params, under the user's file permissions (file.exportPdf could replace
any file, also during --check). It also wrote target, excel and note
strings into generated Rust without escaping. Probes now run inside the
engine's ScriptSandbox, and those fields may not hold quotes,
backslashes, braces or control characters.
2026-11-03 13:16:14 +00:00
Brandon Thomas e1604d1791 Merge PR #3132 2026-11-02 22:36:51 -05:00
Brandon Thomas 74d9641e25 Merge PR #3173 2026-11-02 22:33:26 -05:00
ljkx 342f4b401d Check list validation against the list before the entry
A source that takes in the cell being checked (=$A1:$A2 on A2:A3 is A2:A3 at A3) listed the value being typed, so any new value passed. The list is now read from the workbook as it was before the entry, as in Excel.
2026-11-03 04:26:30 +01:00
Brandon Thomas 95d9ed1234 Merge PR #3161 2026-11-02 22:21:00 -05:00
Brandon Thomas 3d3ff84087 Merge PR #3179 2026-11-02 22:21:00 -05:00
ljkx 179664610c Read relative list validation sources from the cell being checked
A list source such as =$B1:$B2 is written for the rule's top-left cell.
list_items evaluated it there for every cell, so the dropdown and the
check showed and accepted the top-left cell's items everywhere. It now
takes the cell and shifts the source to it, as Excel does.
2026-11-03 03:23:48 +01:00
Brandon ThomasandClaude Opus 5.5 85e51e009c Release 0.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:25:34 -05:00
ljkx fb6dc688bc Rewrite rule formulas for the new top-left cell when cells are removed from a rule
Clearing validation from cells (or setting a new rule, or pasting a
validation over them) and Clear Rules on selected cells shrink a rule's
areas. When that removes the rule's top-left cell, the relative formulas
were left written for the old cell, so the cells left checked other
cells: clearing A1 off a custom `A1>10` on A1:A9 made A2 check A1.

Rewrite the relative references of validation formulas, and of
conditional format expressions and comparison values, for the new
top-left cell. Absolute references and literal lists stay as they are.
2026-11-03 00:18:50 +01:00
Aimar Mendez e5f265bb74 Ribbon test: pin macOS for the AutoSum tooltip, so it passes on a Mac
Since #2729 the ribbon picks a tooltip's shortcut by the OS egui reports
(`ctx.os()`), which egui_kittest pins to Nix unless told. The test still
chose the expected text with cfg!(target_os = "macos"), so on a Mac it
looked for "AutoSum (⌘⇧T)" while the harness drew "AutoSum (Alt+=)",
and `cargo xtask ci` failed there while the Linux gate stayed green.

The harness now pins macOS and the test expects the ⌘⇧T tooltip on every
host; widgets::tests already covers the Windows/Linux "Alt+=" text.

Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with
CRAFT_FONTS_DIR at the gate's craft-fonts commit); on main (7bb397a3)
it fails at test with "tooltip with the shortcut".
2026-11-02 22:19:21 +00:00
PranavandFredApps 7bb397a3c1 UI: RefEdit reference boxes in dialogs (Goal Seek, Solver, Data Analysis) (#494)
* UI: RefEdit reference boxes in dialogs; Goal Seek picks its cells on the grid

A reference box (RefEdit) is a text field that takes a range by selection
on the grid. While a box is the one being picked into (it was clicked into
or collapsed), clicking or dragging on the sheet writes the absolute
reference into it, with the sheet name when the selection is on another
sheet; typing still edits it, and the box takes the keyboard back after a
pick. Focusing another text field of the dialog ends picking.

The collapse button beside each box shrinks the dialog to that box, at the
dialog's top-left corner, so the sheet is free for selecting. Escape,
Enter or the expand button restore the dialog. The sheet and selection
from before picking are restored before the dialog runs its command
(references without a sheet name mean the sheet the dialog was opened
on) and when it closes. The button is focusable, works with Space and
Enter, and is named for screen readers; the icons are drawn in code.

Goal Seek's Set cell and By changing cell are reference boxes. The
snapshot example's --refedit key=range collapses the open dialog to a box
and selects a range, to render the collapsed state.

* UI: Solver and Data Analysis take their ranges with RefEdit boxes

Form dialogs gain a Ref field, drawn as a reference box with its collapse
button. Data Analysis uses it for the input, variable, X and Y and bin
ranges, and for Output Range, where picking or typing a reference also
chooses that output option. Solver Parameters uses reference boxes for
the objective, the variable cells and both sides of a new constraint.

* UI: Data Analysis keeps New Worksheet Ply when chosen after picking an Output Range

The Output Range box chose its option on every frame while it was the box
being picked into, so clicking New Worksheet Ply after using the box
reverted to Output Range on the next frame. The box now chooses Output
Range only when it is clicked into or its text changes (typed or picked),
and choosing another output option ends picking into it, so a later
selection on the grid doesn't fill it either.

* UI: RefEdit boxes remember the dialog's sheet by name

The sheet a dialog was opened on was kept by index, so a sheet inserted
or moved while picking (the ribbon stays usable) made a range on the new
sheet lose its sheet name, and closing the dialog went back to whichever
sheet now had that index. The sheet is now kept by name, as New
Formatting Rule's formula box does.

* UI: switching sheets while picking into a RefEdit box doesn't fill it

A reference on another sheet carries the sheet name, so after a click on
another sheet tab the selection never matched the one last seen, and the
switch alone wrote that sheet's remembered selection into the box,
replacing a typed reference. The box now notes which sheet its last seen
selection was on: showing another sheet only refreshes it, and a
selection made on that sheet still fills the box.

* UI: a RefEdit box for one reference takes only the area being selected

A selection of several areas (Ctrl+click) was written into every box as
a comma-joined union, which Goal Seek, Solver's objective and
constraints, and Data Analysis then refused as an invalid reference.
Solver's variable cells still take every area; the other boxes take the
area being selected.

* UI: RefEdit boxes follow the dialog's sheet when it is renamed

The dialog's own sheet was kept by name only, so renaming it while a
reference box picked made references on it carry the new name as if it
were another sheet, and running or closing the dialog could not put the
selection back. The sheet's index and the sheet count are now kept with
the name: when the name disappears while the count stays the same, the
sheet at that index was renamed and its new name is taken. Inserting,
moving or deleting other sheets still follows the name.

* UI: a deleted dialog sheet is never taken for another sheet

The dialog's own sheet was taken as renamed whenever its name was gone
and the sheet count matched the last frame's, so deleting it was read as
a rename a frame later (or after a sheet was inserted), and the
surviving sheet at that index became the dialog's sheet: unqualified
references ran against it and Cancel put the old selection on it.

The sheet names of the last frame are now kept in order. A rename is
taken only when the list keeps its length and only the home sheet's entry
changed; any other loss of the name marks the sheet as deleted for good.
Once it is deleted, picks carry their sheet's name, closing leaves the
selection where it is, and OK is refused with a message, since
references without a sheet name would otherwise run against whichever
sheet is shown.

* UI: RefEdit dialogs keep their sheet until they close

OK put the saved selection back and dropped the dialog's sheet with it.
When the command then failed, the dialog stayed open without its sheet:
a later deletion of that sheet went unnoticed and a second OK ran
references without a sheet name against the sheet shown, as it also did
when the person had only moved to another sheet after the failed OK.

The sheet is now kept, and followed through renames and deletion, until
the dialog closes; only the saved selection is put back once. A later
OK or Cancel shows the dialog's sheet again (or is refused once it is
deleted), and picking again on that sheet saves its selection anew.

* UI: Solver's Model Range is a RefEdit box

* UI: a collapsed RefEdit box keeps picking when something else is clicked

* UI: a dialog opened over a picking RefEdit dialog puts the selection back

* UI: arrow keys select on the grid while a dialog is collapsed to a RefEdit box

* UI: F4 cycles the anchors of the reference in a RefEdit box

* UI: tests for Enter while collapsed and OK after a cross-sheet pick

* Docs: RefEdit boxes' keyboard route, Model Range and click-elsewhere behaviour

* UI: a confirmed RefEdit dialog stays on the sheet its command showed

* UI: scripted dialog cancel and confirm end a RefEdit pick like the buttons

* UI: RefEdit boxes run their selection and sheet commands through the app

* Docs: RefEdit dialogs after OK, scripted cancel and confirm

* UI: a RefEdit dialog's OK stops when the picked sheet's draft cannot be saved

* Docs: ui.dialog.confirm applies to command-backed dialogs; a draft that cannot be saved stops OK

* UI: Cancel of a RefEdit dialog is never a confirmation, and says when it cannot go home

* Docs: cancelling a RefEdit dialog when its sheet cannot be shown again

* UI: a RefEdit dialog follows its sheet's rename only through the engine's recorded steps

* UI: F4 in a RefEdit box splits areas only at commas outside quoted sheet names

* Docs: how a RefEdit dialog follows its sheet's rename

* UI: a RefEdit dialog's sheet is told from a new sheet given its name

The dialog followed its sheet by name and read the recorded steps only once the name was gone, so a sheet deleted and recreated under its own name between two frames was taken for the dialog's sheet, and OK resolved unqualified references against the replacement. The home sheet is now followed through every recorded step since the dialog last looked: a sheet with its name is the same sheet only while its file id is unchanged (a sheet inserted in the session has none), a rename keeps the file id and the cells, and a deleted sheet is remembered as it was, so only an undo that brings that very sheet back revives it. When the steps are not accounted for, a sheet with the name is taken for it, as before. A sheet never saved to a file and replaced inside one undo group is the one case nothing in the model tells apart; the code comment says so.

* Docs: how a RefEdit dialog tells its sheet from a new sheet given its name

* UI: a RefEdit dialog follows a sheet that came from a file by its file id

A sheet read from a file keeps the file's sheetId, which no step changes and which a sheet inserted in the session lacks, so the dialog's sheet is found by it wherever a recorded step puts it and whatever the step renames it to: a group that renames the sheet and inserts a new one under its old name follows the rename instead of refusing OK. When the step also copied the sheet (a copy keeps the id), the one keeping the name is taken. A sheet never saved to a file is still followed by name, with the rename rule as before.

* Docs: a RefEdit dialog follows a sheet that came from a file by its file id

* UI: a RefEdit box the dialog stops showing is no longer picked into

A reference box compares the grid selection with the one it last saw only while it is drawn, so a box hidden while it was the one being picked into (Solver's Options view, the constraint value once the operator is int or bin) took a selection made meanwhile when it came back. After the dialog's window is drawn, a target box that was not drawn in the frame ends the pick, whatever hid it; Model Range no longer needs its own rule when the Load/Save area is hidden.

* UI: the Name Box keeps its keys while a dialog is collapsed to a RefEdit box

The collapsed picker took Escape, Enter and the arrow keys out of the frame's input whoever had the keyboard. The Name Box, drawn before the dialogs, had already read an arrow when the picker removed the same event and moved the grid selection, and the box then took that selection and the keyboard; its Escape expanded the dialog instead of only leaving the box. The picker now acts on those keys only when no other text field owns the keyboard, judged by the focus at the end of the frame before as well as now, since egui drops the focus for Escape before anything is drawn. The other field's Escape and Enter are still used up, so the dialog neither closes nor confirms on them.

* Docs: a sentence break in the ui.dialog row of the control protocol

* UI: a RefEdit dialog keeps its sheet by the sheet's identity, not by file id or history

The dialog's sheet was followed by name through the recorded undo steps, with the file's sheet id telling a saved sheet from a new one; a copy made with sheet.move keeps that id, so once the original was deleted the copy, as the only sheet of the id, was taken for the dialog's sheet and OK ran against it. The model now gives every sheet an identity while the app runs (SheetUid: fresh for every sheet added, inserted, copied or read, kept through renames, moves, undo and redo), so Home keeps the sheet's uid and finds the sheet by it each time it is needed. The history walk, the rename rule and the file-id and Arc comparisons are gone, with the undetectable single-step case they left: a sheet given the name, a replacement inside one undo group and a copy are all other sheets, and an undo that brings the original back makes it the dialog's again. Nothing of the unaccounted-steps fallback is kept, since the identity always answers. Regression test: copy the dialog's sheet, delete the original, OK is refused; undo, OK runs on the original. The identity tests no longer need a workbook that came through a file.

* UI: Solver's Reset All, Load, Save and Replace act on the dialog's sheet, as Solve does

Only Solve went through the dialog's home-sheet checks before running (dialogs::show): Reset All, Load Model, Save Model and Replace ran their commands straight from the form, on whichever sheet was active. After a reference box picked on another sheet and the dialog expanded, that sheet was still shown, so Reset All removed its model instead of the dialog's sheet's, and Load and Save read and wrote the model range on it. The four buttons now run only after the same checks as Solve: the dialog's sheet must still exist (else the command is refused with the deleted-sheet message) and is shown again with the selection from before picking put back (a failure to show it, a text-box draft that could not be saved, is shown as the dialog's error and the command does not run). Goal Seek has no other buttons, and Data Analysis's Replace goes through the confirm path that Solve uses, so neither needed a change. Regression tests: after a cross-sheet pick, Reset All clears the dialog's sheet's model and leaves the other sheet's; Save and Replace write the dialog's sheet; Load reads it.

* UI: Protect Sheet opened while a reference box picks on another sheet acts on the dialog's sheet

* Docs: a scripted Goal Seek after a cross-sheet pick cancels the dialog before data.goalSeek

* UI: the Protect Sheet labels follow the dialog's sheet while a reference box picks on another

* Docs: a scripted Goal Seek checks that the dialog's sheet still exists after cancel

* Control: ui.dialog.cancel replies sheetDeleted when the dialog's own sheet is gone, even if its name was reused

* M2: allow shared-runner variance in the pathological math timing gate

* M2: refuse sheet protection actions after a reference dialog home is deleted

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-02 21:23:18 +01:00
FredApps ef927e2e71 Merge pull request #1576 from danielmigueltejedor/fix/gnome-window-decorations
Restore GNOME Wayland window controls without bundled fonts
2026-11-02 19:33:06 +01:00
FredApps 6558f26ba8 Merge current main into fix/gnome-window-decorations 2026-11-02 19:10:45 +01:00
Neil Cawse eea1fc276f Charts: decimate area top and baseline at paired horizontal indices 2026-11-02 17:52:23 +00:00
ljkx adcb66697c Data validation and conditional formats: anchor relative formulas on the top-left cell of all areas (#3152)
Excel writes the relative formulas of a multi-area rule for the top-left
cell of the whole sqref (lowest row, lowest column), whatever order the
areas are listed in. GridCraft anchored them on the first area listed, so
a validation on `H62:H68 H26:H27 H93:H95` with `MOD($H26,$F26)=0` checked
row 26 - 36 at H26 and refused valid input.

Add `RangeRef::top_left` and use it in validation checks, list sources,
pasting a validation, conditional format lookups and the formulas the
xlsx writer generates for text, blank and error rules.
2026-11-02 18:29:23 +01:00
FredApps 9172516aa8 Merge pull request #2639 from prxnvt/pr/python-cell-ui
Python cells in the interface: formula bar Python mode and the output switch (no code runs)
2026-11-02 17:31:18 +01:00
FredApps 824a6fffd2 Merge pull request #2637 from prxnvt/pr/python-cells
Python cells as data: stored, edited and saved as Excel's PY formula (no code runs)
2026-11-02 17:30:47 +01:00
prxnvt 9c6dbfdfab UI: the code field draws the equation editor's error mark again
The merge of main brought #471's code_editor_marked, whose mark was drawn by the
layouter inside the code editor; on this branch that layouter lives in code_field,
which had no mark, so the merge did not compile. FieldOptions gains mark (None by
default), code_editor_marked passes it, and code_field underlines it as before.

Test: the_marked_editor_underlines_its_mark (fails when the field ignores the mark).
2026-11-02 10:46:17 -05:00
prxnvt e9430daa9a Merge the updated Python cells branch (7ddcb800, main 6f52cc50) into the Python mode layer
Conflicts in ROADMAP.md, docs/architecture.md and docs/ui-parity.md only: header lines
combined, both sides' sections and rows kept.
2026-11-02 10:38:53 -05:00
prxnvt 7ddcb800ea Merge origin/main (6f52cc50) into the Python cells branch
Main gained #471 (equations) and #2729 (command palette shortcuts). Conflicts in
ROADMAP.md, docs/architecture.md and docs/file-format-parity.md only: header lines
combined, both sides' sections and rows kept.
2026-11-02 10:38:38 -05:00
PranavandFredApps 6f52cc5090 Equations: Insert › Equation with a LaTeX editor, Office Math in xlsx and equations in PDF (#471)
* Objects: one selection for charts, pictures, shapes and slicers, with eight resize handles

The sheet tracked the selected object as a bare id in `selected_chart`, so
only charts and text boxes drew a selection frame, only the bottom-right
corner resized, and the kind of the selected object had to be guessed from
the id. The selection is now `SheetApp::selected_object: Option<ObjRef>`,
with `selected_chart()` kept for the Chart Design tab and the Format Chart
pane.

- Model: `ObjKind` (chart, image, shape, slicer) and `ObjRef { kind, id }`;
  `Sheet` helpers find, move and remove an object by `ObjRef` and walk every
  object's anchor in a fixed order. `next_object_id` saturates instead of
  overflowing on an id at `u32::MAX` read from a file.
- Commands: `object.delete`, `object.move` and `object.setAnchorMode` accept
  the four kinds through one parameter check, refuse an unknown kind or id
  instead of succeeding silently, and clamp hostile offsets. `object.delete`
  replies with the deleted object. Arrange and the Selection Pane include
  slicers, which stack above the other objects.
- Sheet: the selected object of any kind draws a frame with eight handles
  over every object. Each handle resizes only its own edges, which never
  cross; the body moves the object, and the first drag frame already applies
  the pointer's movement. The pointer shows the handle's resize cursor or a
  move cursor, with the diagonals swapped on a right-to-left sheet, where the
  frame moves with its object.
- Keys: Escape drops the object selection; Delete and Backspace keep
  deleting the selected object (#213). A selection whose object disappears
  (undo, redo, delete, sheet switch) is cleared.
- Double-click: `open_object` routes by kind (a text box edits in place, a
  chart opens Format Chart).
- Control protocol: `ui.inspect` reports `selectedObject {kind, id}` and
  keeps `selectedChart`; `ui.object.select` selects any object and replies
  with its rect. The snapshot example takes `--object <id>`.

Tests: engine tests for every kind by kind and id, the Selection Pane and
hostile parameters; UI tests for selecting each kind (a slicer over a chart
takes the click), all eight handles on four kinds, cursors, Delete,
Backspace and Escape, undo and redo, double-click routing, and resizing on a
right-to-left sheet. Existing tests use the new selection API.

* Objects: every kind follows edits; chart series follow cut-moves and sheet deletes

Structural edits and sorts kept three hand-indexed lists (charts, images,
shapes) in step in two places, and slicers were left out of both, so a
slicer stayed put when rows were inserted above it. Chart series were
rewritten for inserted and deleted lines and for sheet renames, but not for
cut-moves or sheet deletes, which left them pointing at the old cells.

- Inserting and deleting rows and columns and sorting walk
  `Sheet::object_anchors` and `object_anchors_mut`, one fixed order over
  every kind, so slicers now follow their cells by their anchor mode like
  the other objects, and a new kind cannot be forgotten in one of the
  passes.
- `rewrite_cell_links` is the one place that rewrites the references
  floating objects hold (chart series values, categories and names,
  and the source block a chart was made from).
  `rewrite_all_formulas` calls it, which covers inserting and deleting
  lines and cells and cut-moves on one sheet or across sheets; renaming and
  deleting a sheet call it too, so a series into a deleted sheet becomes
  #REF!, as a formula does.
- Copying a sheet gives its charts, pictures, shapes and slicers fresh ids,
  keeping ids unique across the workbook, which object selection and the
  `{kind, id}` commands rely on.

Tests: every kind through row and column inserts and deletes, an absolute
shape, a sort and undo; a slicer through inserted rows; chart series
through a cut-move, a sheet rename, a sheet delete and undo; ids after a
sheet copy.

* Objects: bubble sizes follow edits like the other series references

`rewrite_cell_links` rewrote a chart series' values, categories and name
but not its bubble sizes, so inserting or deleting lines, a cut-move or a
sheet rename left a bubble chart's sizes pointing at the old cells. They
are now rewritten with the rest.

Test: bubble sizes through an inserted row, a cut-move and a sheet rename.

* Objects: a resize handle under the headers leaves the press to them

A selected object's handles reach a few points outside it, and a press
was matched against them before any check of where it landed, so a press
on the column or row header beside an object at the sheet's edge resized
the object instead of selecting the column or row. The cursor already
ignored handles outside the cells.

`chartview::selected_handle` finds the selected object's handle under a
point only over the cell area, as `hit` does, and not while objects are
hidden; the press and the cursor both use it.

Test: presses within a handle's reach over the column header and the row
header neither move nor resize the object.

* Objects: each drag of an object is its own undo step

A drag merges its frames into one undo step by dropping a new step whose
label matches the one before it. Its first frame did the same, so a second
drag of an object (a move after a move, or a resize after a move) merged
into the first drag's "Move Object" step, and one undo took back both.

`Drag::Object` records whether the drag has made its step; only the later
frames of the same drag merge into it.

Test: a move and then a resize of a picture take two undos to reverse,
the first restoring the moved picture.

* Objects: the parity and gap docs no longer list chart resizing as missing

gaps.md (§4), target-app-parity.md (the Essentials tier's work, the
Mainstream chart row, the interaction discount and the Essentials chart
task) and chart-parity.md (the element table and the work list) still
described charts as impossible to resize (#190). Charts move and resize
from eight handles like every floating object, so those statements now
say so; the 2026-10-10 revision rows and the 10-10 re-check stay as
history. Scores and the 40-60 h chart estimate are unchanged (resizing
was 1-2 h, within the rounding).

* Objects: one chart evidence paragraph in gaps.md; trendlines are not missing

gaps.md §4 carried two evidence paragraphs, an older one saying PRs #158
and #161 were open and a newer one saying all 2-D kinds survive XLSX round
trip, both repeating the resize, smooth-line, Switch Row/Column and
trendline statements. It is one paragraph with the current status again.

The Mainstream chart row in target-app-parity.md listed trendlines as
missing; they are read, written and drawn (`Series::trendlines`, the XLSX
chart reader and writer, `chart.addElement`), as chart-parity.md records.
The row now says so and keeps "no axis options". No score change.

* Objects: only the selected object's handles resize; a press elsewhere on an object moves it

A primary drag looked for a handle under the press on whatever object it
grabbed, so pressing near the edge or corner of an object that was not
selected started a resize, while the pointer over that spot showed the
Move cursor (handles are only shown, and only offered by the cursor, on
the selected object). The press now takes its handle from
`chartview::selected_handle`, the same lookup the cursor uses; a press on
any other object selects and moves it.

Test: dragging an unselected shape by its bottom-right corner shows the
Move cursor, then selects and moves the shape without resizing it.

* Equations: gridcraft-math lays LaTeX out with RaTeX, behind caps and a panic guard

Equations need a LaTeX layout engine. RaTeX (ratex-parser, ratex-layout,
ratex-types, ratex-font; MIT) turns LaTeX into positioned glyphs, rules and
paths. Before adopting it, a gate compared it with writing an in-house
subset:

- Source scan: about 31,700 lines, 95 non-test panic sites (87 in the
  parser, mostly unwraps on arguments already counted and fixed regular
  expressions), no unsafe code, no clocks, threads, file or environment
  access.
- Fuzzing: 70,000 random token strings and 50,000 mutations of valid
  equations with no panic and no input slower than 5 ms. Macro bombs stop
  at 1,000 expansions; nesting deeper than 32 is refused, and the deepest
  accepted input needs under 256 KB of stack.
- The crates build for wasm32-unknown-unknown.
- Syntax coverage: 56 of 57 probes of everyday notation lay out.

The gate passes, so RaTeX is used, pinned exactly at 0.1.14. The new
gridcraft-math crate (layer 1, registered in the layering table) is the only
code that calls it:

- `layout` turns LaTeX into a `MathList` of plain glyph, rule and path data
  in em units, with faces (roman, italic, bold, sans, mono, large operator
  sizes), explicit colours and a plain fallback character for glyphs a font
  may lack. `check` only parses. Both cap the source at 4 KB and the nesting
  at 32 before RaTeX sees it, cap coordinates and item counts in the result,
  and run under `catch_unwind`, so a panic becomes a `MathError` with the
  offending span when RaTeX reports one.
- `to_omml` writes the common subset as Office Math (ECMA-376 Part 1, 22.1):
  runs and font styles, fractions and binomials, radicals, scripts, n-ary
  operators with limits, functions and limits, delimiters, matrices,
  accents, bars and braces. Anything else is an error, so the caller can
  save the equation as text instead.

crates/math/tests/gate.rs re-runs a smaller, deterministic version of the
gate (fuzzing, depth, macro bombs, coverage) on every test run, so a version
bump has to pass it. docs/decisions.md records the evidence and the guard
design. RaTeX positions glyphs with KaTeX's font metrics (MIT); no KaTeX
font files are bundled.

* Equations: Insert › Equation, an equation editor, and equations on the sheet

Equations are shapes (`ShapeKind::Equation`) whose text is their LaTeX
source; they select, open and delete as their own object kind
(`ObjKind::Equation`).

- Model: `Shape::math`, an `EquationCache` holding the layout of the
  source (or the reason it has none), keyed by a hash of the source. It is
  never saved and is ignored when shapes are compared.
  `Workbook::refresh_equations` lays out only the equations whose source
  changed, copying only the sheets that hold one. Every new or opened
  document (`DocState::new`, `DocState::from_saved`) and every commit
  call it, so files, pastes and undo snapshots always arrive with a layout
  and painting never lays anything out.
- Commands: `insert.equation {latex, cell?|at?, size?, color?}` sizes the
  frame for the requested font size (11 pt by default) and anchors it to
  move, but not size, with cells. `equation.edit {id, latex}` keeps the
  scale the equation is drawn at and refits the frame, in one undo step.
  Missing source, or source that does not lay out, is an error naming the
  character position ("equation error at character 5: Undefined control
  sequence: \foo"). An equation is drawn to fit its frame, so resizing the
  frame scales it.
- Painting reads only the cache: glyphs in system maths font roles
  (`theme::MATH`, `MATH_ITALIC`, `MATH_BOLD`: STIX Two, Cambria Math, Latin
  Modern, DejaVu or Liberation Serif, then Times; nothing is bundled), large
  operators and delimiters stretched to their TeX boxes, rules, and
  outlines filled by ear clipping. A glyph no face draws becomes a [?] box;
  since egui's `has_glyph` misreports the glyphs of the face that supplies
  the replacement glyph, a negative answer is confirmed by comparing atlas
  slots. Ink defaults to the sheet's text colour. Source that does not lay
  out (from a file) is shown as text in a dashed frame.
- Ribbon: Insert gains a Symbols group with an Equation split button before
  Symbol. The main half (access keys N E, previously reserved in
  keytips.json) opens the editor; the menu has Insert New Equation... and a
  gallery of GridCraft's own examples (`gridcraft_math::GALLERY`), each
  drawn as a preview and inserted with one click. The new labels are
  translated for every interface language, and the Equation icon is drawn
  in code.
- Editor (dialog "equation", also driven by `ui.dialog.set` and
  `ui.dialog.confirm`): quick-insert buttons for common constructs, Greek
  letters and relations, inserted at the caret with the caret moved into the
  first `{}`; the code editor in LaTeX mode with the error's span underlined
  (`code_editor_marked` and `code_editor::underline`); the error with its
  character position, or a live preview on a sheet-coloured card; OK is
  disabled while the source has an error, and Cmd/Ctrl+Enter runs
  `insert.equation` at the active cell or `equation.edit`. Double-clicking an
  equation opens the editor on its source.
- xlsx writes an equation as a text box holding its source for now.

The keytip test that used `insert.equation` as its stand-in for a pending
command now uses `insert.object`. Docs: architecture (the math crate and the
cache), UI, gaps and target-app parity, ROADMAP; the parity checklist is
regenerated.

Tests: engine tests for insert sizing, size and colour, undo, hostile and
bad source with positions, edit scale and refit, no-op edits, refusals,
protected sheets, the cache rebuilt on load and not saved, a bad source
from a file, and equations in the object tests; UI tests for opening the
editor from the ribbon and with N E, typing an equation in, the error
position blocking OK, quick-insert at the caret, double-click editing as
one undo step, the gallery, an equation in the selection and resize tests,
ear clipping, curve flattening and underline splitting.

* Equations: Office Math in xlsx, and the Symbol font in PDF

xlsx: an equation is written as mc:AlternateContent. The a14 choice is a
text box holding the equation as Office Math (a14:m with m:oMathPara, from
gridcraft_math::to_omml at the size the equation is drawn at, in its ink
colour); the fallback is the same text box with the LaTeX as plain text, so
other readers still show the source. Both carry
descr="gridcraft:latex:<source>". Source outside the Office Math subset is
written as the text box alone.

On reading, the descr source wins, exactly (quotes and newlines included).
Without it, as in files from other writers, the new xlsx::omml reader
converts the common subset back to LaTeX: runs (upright, text,
double-struck, script, Fraktur, sans and bold styles, operator names),
fractions, radicals, scripts, n-ary operators with limits, delimiters with
separators, matrices, equation arrays, accents, bars, group characters,
lower and upper limits, functions, border boxes and phantoms. Anything else,
or a result that does not parse, stays a text box. The reader is
depth-bounded. The drawing reader now takes an mc:Choice that holds Office
Math, not only chart frames. xlsx may now depend on gridcraft-math
(FORMAT_ALLOWED), a pure layer-1 crate.

PDF: crates/pdf gains Font::Symbol (the thirteenth standard font, written
without WinAnsiEncoding) with its Unicode-to-Symbol encoding table and the
advance widths of Adobe's Core 14 Symbol.afm, cited in symbol.rs as
metrics.rs cites the other faces; Font::can_encode tells callers what a face
can show. Symbol text is written as one run in Symbol's own encoding, not
split off to the CID font that the text faces use for characters WinAnsi
lacks. Printing and PDF export draw an equation's cached layout: letters
and digits in Times (italic for variables) or Helvetica and Courier for sans
and monospace styles, Greek letters and operators in Symbol, large operators
and delimiters stretched to their TeX boxes, the radical fitted to meet its
vinculum, rules as filled rectangles and outlines as filled polygons. A
glyph outside WinAnsi and Symbol (after its plain fallback, R for a
double-struck R) prints as a [?] box.

Docs: file-format parity, gaps, target-app parity and ROADMAP.

Tests: xlsx round trips (choice and fallback markup, descr, colour, anchor
mode, an equation outside the subset, hostile sources, a text box left
alone), Office Math without a source read back to the same glyphs, the
reader's subset, unsupported constructs and the depth bound, and gallery
write-then-read; Symbol encoding and widths for every mapped character; a
PDF with Symbol and Times-Italic, the summation and alpha codes, and a [?]
box.

* Equations: the error underline snaps to char boundaries, so a stale mark cannot crash the editor

The equation editor computes the error's byte range from the source as it
was before this frame's edit, and `code_editor::underline` only clamped the
range to the text's length. Typing a multibyte character in front of the
error (`a\foo` with its error at 1..5, then `é` at the start) put a section
boundary inside the `é`, and egui's layout panicked slicing the text there,
in the paint path, where no guard catches it.

`underline` now moves the mark's start down and its end up to the nearest
char boundaries, so any byte range is safe.

Test: a_stale_mark_inside_a_multibyte_char_snaps_to_char_boundaries marks
every range over texts with two-, three- and four-byte characters, checks
that every section is on char boundaries and covers the text, and lays each
job out; it fails without the fix.

* Equations: the editor focuses its source on every opening, however the last one closed

The editor kept a "focused once" flag in egui memory and removed it only
on OK, Cancel and Cmd/Ctrl+Enter. Escape and the window's close button
also close the dialog, and left the flag set, so the next Insert ›
Equation or Edit Equation opened without focus on the LaTeX source and
typing went nowhere.

The flag is now the dialog's own `focus_initialized` (made visible to the
crate), as Command Search uses it: every opening builds a new dialog with
it unset, whatever closed the previous one.

Test: reopening_after_escape_focuses_the_source_again opens the editor
twice, types into it and closes it with Escape each time; it fails
without the fix (the second round's typing is lost).

* Equations: Office Math the reader cannot convert keeps the writer's fallback

The drawing reader took an `mc:Choice` whenever its text box held Office
Math, before knowing whether that Office Math converts to LaTeX. Office
Math outside the reader's subset, without GridCraft's source in `descr`,
then became a text box with no text, and the `mc:Fallback` (the picture or
text the writer saved for readers like this one) was never read.

The choice is now taken only when `omml::to_latex` converts its Office
Math; otherwise the fallback is read. GridCraft's own files are
unaffected: their fallback carries the same source in `descr`.

Test: office_math_outside_the_subset_reads_the_fallback replaces a saved
equation's Office Math with a pre-subscript (outside the subset), removes
the source and gives the fallback its own text; it fails without the fix
(the shape is read with empty text).

* Equations: hostile LaTeX through the guarded entry points and the xlsx round trip

The gate fuzzes RaTeX itself. These tests fuzz what GridCraft's callers
reach:

- the_guarded_entry_points_survive_hostile_input runs `layout`, `check`
  and `to_omml` over random token soup (any Unicode scalar included),
  inputs at and past the nesting and length caps, huge rules and a macro
  bomb: none panics, every layout stays within `MAX_EM` and `MAX_ITEMS`,
  every error span is on char boundaries inside the source, and what lays
  out also checks.
- hostile_sources_round_trip_exactly saves 200 sources built from XML and
  LaTeX specials (quotes, `<`, `&`, `]]>`, comments, CR, LF, tab, emoji,
  CJK, unbalanced braces) to xlsx and reads each back as the same equation
  source.

Both pass on the current code; they guard the caps and the writer's
escaping against regressions.

* Equations: opening a file lays out a bounded amount of equation source

`Workbook::refresh_equations` laid out every stale equation on load and
on every commit. One 4 KB equation can take about a tenth of a second to
lay out, so a hostile or simply large file with thousands of equations
held the app for minutes before it showed anything.

One call now lays out at most `MAX_EQUATION_LAYOUT_BYTES` (128 KB, about
2,500 typical equations) of source. Equations past it get an error
(`EquationCache::refuse`, without running the layout engine), so they show
their source in the dashed frame and later commits do not retry them.
`equation.edit` with an unchanged source now lays out an equation that has
no layout, so OK in the editor brings one back. Architecture docs note the
budget.

Test: a_file_with_too_many_equations_lays_out_a_bounded_amount opens a
workbook whose equations exceed the budget, checks that the laid-out
source stays within it, that the rest carry the budget's error and are not
retried by a later edit, and that `equation.edit` lays one out; it fails
without the budget (every equation is laid out).

* Equations: the layout cache answers only for the source it was built from

`EquationCache` matched an entry by a 64-bit hash of the source
(`DefaultHasher`, fixed keys). A crafted pair of sources with the same
hash, for example one in a file and one typed by the user, would make the
cache return one source's drawing for the other.

An entry now keeps its source and compares it in full on every lookup;
the hash and length are only a quick first check. A source over the
4 KB length cap is not copied into the entry: it never lays out, so its
entry is always an error, and it is matched by length and hash, and only
by another source over the cap. Kept sources are shared (`Arc<str>`), so
copies of a sheet and undo snapshots do not duplicate them.

Tests: a_hash_collision_never_returns_another_sources_layout forges a
collision by giving an entry for `x^2` the hash of `y^3` (same length) and
checks that `y^3` misses, is laid out afresh, and that a refusal does not
answer for a colliding source either; it fails without the fix (`y^3` gets
the layout of `x^2`). sources_over_the_cap_are_not_copied_and_never_lay_out
checks the cap path.

* Equations: a text box with Office Math beside text or other maths stays a text box

The drawing reader made any shape whose text held Office Math into one
equation, taken from the first `m:oMath`. A text box from another writer
with text beside the maths ("Solve: " and an inline equation), or with two
equations, lost the text, the other equation and the shape's formatting.
The `mc:Choice` filter used the same check, so the writer's fallback was
skipped as well.

`omml::equation_source` now converts a text body only when it holds one
equation and nothing else: no text in its runs or fields and no second
`m:oMath`/`m:oMathPara`. The Choice filter and `read_shape` both use it, so
a mixed box reads the fallback (inside alternate content) or stays a text
box with its text.

Test: office_math_beside_text_or_other_math_is_not_an_equation covers a
text run before the maths in the a14 choice (the fallback's text is read),
the same box without alternate content (a text box that keeps "Solve:"),
and two equations in one box (not an equation). It fails without the fix
(the first case reads an equation); the two-equation case fails when only
the second-equation check is removed.

* Equations: a new object id is a free one even when a file used the largest id

`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a JSON file can carry an
object with that id; every later insert (an equation, chart, picture,
shape, table or slicer) then got `u32::MAX` again, and so did each object
of a copied sheet, whose ids also came from a saturating counter. Lookups
and selection take the first object with an id, and `object.delete`
removes every one.

`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead.

Test: ids_stay_unique_when_a_file_used_the_largest_id opens a workbook
whose equation has id u32::MAX, inserts two equations and copies the
sheet; every id is distinct. It fails without the fix (both new equations
get 4294967295). object_ids_never_overflow_or_repeat covers the gaps and
the order.

* Equations: the control protocol and UI parity list equation among the object kinds

`ui.inspect` reports a selected equation as `selectedObject: {kind:
"equation", id}`, and `object.delete`, `object.move`,
`object.setAnchorMode` and the `arrange.*` commands take `equation` as a
`kind`, but docs/control-protocol.md listed only chart, image, shape and
slicer. It now lists equation too, as does the floating-objects row of
docs/ui-parity.md.

* Docs: ui.object.select says what it replies when it clears the selection

* Objects: a test pins that both anchor accessors visit the same objects in the same order

Sorting and row or column edits read every anchor through
`object_anchors`, compute the new ones and write them back through
`object_anchors_mut` in step. Nothing checked that the two accessors
visit the same objects in the same order; a kind of floating object
reaching only one of them would have paired objects with other objects'
anchors silently.

Test: two objects of every kind are inserted in a mixed order, each
anchor is marked with its position through the mutable accessor, the
marks are read back through the shared one in the same sequence, the
count matches the per-kind collections, and the mark is found on the
object `object_anchor` names by kind and id. The test's constructor
matches on `ObjKind` exhaustively, so a new kind does not compile until
the test covers it.

* Objects: sorting keeps the size of a "move and size" picture whose lines swap places

A picture anchored to move and size with cells and spanning two rows
(or columns) was re-fitted between its top-left and bottom-right lines
after a sort. When the sort put the bottom line above the top one, the
fitted extent was negative and the picture collapsed to a zero height
or width. It now keeps its old size on that axis and travels with its
top-left line, like a "move only" picture.

Tests: for a sort by rows and one by columns, "move and size" and
"move only" pictures inside one line travel with it (the first
re-fitted to the same line, the second at its own size), an absolute
picture stays while the cells move under it, a two-line picture whose
lines swap keeps its size, and undo restores every anchor.

* Equations: Office Math whose LaTeX passes the source cap reads the writer's fallback

The OMML reader (crates/xlsx/src/omml.rs) bounded nesting (`MAX_DEPTH`
64, `find_math` depth 8) but not the length of the LaTeX it produced: a
drawing with 100,000 sibling runs, or one run of megabytes of text, was
converted in full and only then refused by `gridcraft_math::check`,
which stops at `MAX_SOURCE_BYTES` (4,096). A hostile file paid for the
whole string, and each backslash in a `\text` run grew to
`\textbackslash ` first.

`convert` (the conversion before the final check) now stops with `None`
as soon as the LaTeX produced passes `MAX_SOURCE_BYTES`: after every
node in a sequence, and after every character of a run's text, in the
escaped `\text{…}` form as well as the plain one. The check reads the
output, not the run's byte length, because a character may become a
longer macro (`\alpha `) or a shorter sign (a minus sign becomes `-`).
The caller then keeps the writer's text fallback, as for any equation
outside the subset. An equation exactly at the cap still reads back.

Regression test: `omml::tests::latex_past_the_source_cap_stops_converting_and_falls_back`.
Without the checks it fails with `left: Some("xxx…")` (a 100,000-byte
string), `right: None`. The test reads the clock, which clippy
disallows for the sake of wasm; tests never run there, and the lint is
allowed on that function.

* Equations: the hostile-source test covers equation.edit as well as insert.equation

`insert.equation` and `equation.edit` both lay a source out through
`cmd::equation::lay_out`, so both refuse a source over 4,096 bytes or
nested deeper than 32 levels, but only the insert side was asserted.
`insert_refuses_missing_bad_or_hostile_source` now edits an equation
with the same over-long and over-deep sources, expects the same errors,
and checks that the equation's source is unchanged afterwards.

* Equations: ATTRIBUTION.md records the Core 14 AFM numbers behind the PDF Symbol font

PDF export draws an equation's Greek letters and operators with the
standard Symbol font and its letters with the Times faces; no font file
is embedded (the base-14 fonts are the reader's own, and text outside
WinAnsiEncoding uses a predefined, non-embedded Adobe-GB1 CID font). What
the repository holds are numbers: Symbol's character codes and advance
widths in crates/pdf/src/symbol.rs, and the Helvetica, Times and Courier
widths in crates/pdf/src/metrics.rs, typed in from Adobe's Core 14 AFM
metrics, which Adobe distributes for use, copying and distribution for
any purpose (the notice is quoted in metrics.rs). ATTRIBUTION.md now
carries a row for those numbers, their source and that notice, so the
licence is recorded where every other third-party input is.

* Equations: the gate times pathological sources at the cap, chemistry included

`ratex-parser` uses `fancy-regex`, a backtracking engine, for its port of
mhchem: anchored patterns with lookahead tokenise the argument of `\ce`
and `\pu`, so user LaTeX does reach them. The engine stops a match after
its default 1,000,000 backtracking steps and the match counts as none,
and `gridcraft-math` caps the source at 4,096 bytes before the parser
sees it, but nothing measured how long a source built to make those
patterns (or the hand-written parser) work hardest could take.

`pathological_source_at_the_cap_finishes_in_bounded_time` in
crates/math/tests/gate.rs lays out, checks and converts 38 sources of
exactly `MAX_SOURCE_BYTES`: groups, delimiters, fractions, roots,
scripts, accents, `\text` and matrices nested to `MAX_DEPTH`; runs of
`\\`, `\{`, `{}`, primes, thin spaces and alignment tabs; a `(a+)+b`
shape; wide and tall environments; macro definitions, colours and
combining marks; and chemistry (`\ce` with digit runs, decimals,
parentheses, arrows, scripts, dollars, formulas, nested `\ce`, charges
and fractions; `\pu` with charges, exponents and unit slashes). Each
must finish inside 10 s through the guarded entry points; the slowest,
`\ce{+-+-…}`, takes 0.8 s in a debug build on an Apple M series
machine, so catastrophic backtracking would fail the test without a
slow CI runner failing it. The test reads the clock, which clippy
disallows for the sake of wasm; tests never run there, and the lint is
allowed on that function. docs/decisions.md records the finding in the
RaTeX gate.

* Objects: a copied sheet's objects get free ids even when a file used the largest id

`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a file can carry an object
with that id; every later insert (a chart, picture, shape, table or
slicer) then got `u32::MAX` again, and so did each object of a copied
sheet, whose ids also came from a saturating counter. Lookups and
selection take the first object with an id, and `object.delete` removes
every one.

`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead,
and a new one covers a file that used the largest ids: the free ids come
from below, 100,000 of them distinct and in order.

Test: object_ids_stay_unique_when_a_file_used_the_largest_id gives a
shape id u32::MAX, inserts two shapes and copies the sheet; every shape id
is distinct across both sheets. It fails without the fix (both new shapes
get 4294967295).

* Equations: PDF prints an equation without a layout one source line at a time

When an equation has no layout (its source does not parse, or the file
was past the open-time layout budget), PDF export printed the source in
one `pg.text` call. A newline is not a WinAnsi character, so each one
became a literal `?`, and a multi-line source ran on in one line. The
xlsx plain-text fallback already writes one paragraph per line.

`fallback_lines` in crates/engine/src/cmd/print/objects.rs splits the
source at newlines and yields the lines the frame holds at 12 pt per
line (at least the first), never more than `MAX_FALLBACK_LINES` (500);
the fallback arm prints each on its own baseline.

Regression test:
`cmd::print::tests::an_equation_without_a_layout_prints_its_source_one_line_per_line`.
Without the change it fails at "the first line is its own text run"
(the PDF holds `(frac{?second line) Tj`).

* Equations: Office Math runs drop the characters XML cannot carry

`esc` in crates/math/src/omml.rs dropped C0 controls other than tab and
newline but kept U+FFFE, U+FFFF and the carriage return as they were.
The xlsx writer embeds the OMML in the drawing part as it is, so a
source holding one of them produced XML no parser accepts, where the
rest of the drawing part (escaped through the xlsx crate's `esc`, which
filters through `is_xml_char`) would have dropped it.

`esc` now keeps exactly the XML 1.0 character set the xlsx escapes keep
(tab, newline, carriage return, U+0020 to U+D7FF, U+E000 to U+FFFD and
the supplementary planes), writes the carriage return as `&#13;` and
drops the rest. The predicate is a copy of the xlsx one: the math crate
sits below the xlsx crate and cannot import it.

Regression test: `omml::tests::characters_xml_cannot_carry_are_dropped_from_runs`.
Without the change it fails with `left: "a\u{fffe}b\u{ffff}cde\u{7f}f"`,
`right: "abcde\u{7f}f"`.

* Equations: the matrix writer writes every row RaTeX gives it

The OMML matrix writer skipped a row whose only cell was an empty
`OrdGroup` whenever the matrix had more than one row, meant to drop the
empty last row a trailing `\\` leaves. Read against RaTeX 0.1.14 the
branch was unreachable: the parser removes that trailing empty row
itself (its `environments.rs`, "Check for trailing empty row and remove
it"), and every cell it does give is a `Styling` node around the group,
never a bare `OrdGroup`. A blank row in the middle, written as `\\ \\`
or as `{}`, was therefore already kept, but the code said otherwise and
would have dropped such a row had the parser's shape matched.

The skip is gone; the writer writes every row it is given. The new
tests pin the behaviour so that a RaTeX release that stopped dropping
the trailing row would show up: `omml::tests::only_a_trailing_empty_row_is_dropped`
(two rows for `a \\ b \\`, three for `x \\ \\ y` and for `a \\ {} \\ b`,
one for a lone `\\`) and, through the xlsx reader,
`omml::tests::a_blank_middle_row_comes_back_and_a_trailing_one_does_not`.

* Equations: to_omml takes the ink colour instead of the xlsx writer patching its output

The xlsx writer coloured the Office Math runs by replacing the exact
text `<a:latin typeface="Cambria Math"/></a:rPr>` in `to_omml`'s output
with the fill in front of it. Had that text changed, `replace` would
have done nothing, the Office Math choice would have lost its colour,
and nothing would have reported it; the round-trip test could not have
caught it either, because the plain-text fallback carried the same
colour and the reader would have returned it.

`gridcraft_math::to_omml` now takes the ink as `Option<[u8; 3]>` and
writes `<a:solidFill><a:srgbClr …/></a:solidFill>` into every run's
properties itself; the xlsx writer passes the resolved shape colour and
the string patch is gone. Tests: `omml::tests::runs_carry_the_ink_colour_when_one_is_given`
(three coloured runs for `x+1`, none without an ink), and
`equations_write_office_math_with_a_text_fallback_and_read_back` now
asserts the colour inside the `<a14:m>` paragraph. Without the fill in
the writer both fail (`left: 0, right: 3`; "the Office Math runs carry
the ink colour").

* Equations: what shows a source as text shows its first 4,096 bytes

The PDF fallback for an equation without a layout capped the number of
lines it printed but not their length: `fallback_lines` scanned the
whole source and handed a complete line to `Page::text`, which formats
it before the page-content limit can refuse it. A source read from a
file is not bounded by `MAX_SOURCE_BYTES` (the editor and the commands
refuse a longer one, but a drawing part may be 512 MiB), so a hostile
file put megabytes into one text run. The sheet painter laid the whole
source out every frame for the same reason.

`cmd::equation::source_excerpt` returns the first `MAX_SOURCE_BYTES`
bytes of a source, cut at a char boundary; a longer source never lays
out, so that is all a reader of the text can use. `fallback_lines`
splits the excerpt, and the sheet painter's dashed frame shows it.

The stored source is left as the file wrote it: the xlsx reader keeps a
`descr` source whole, as it keeps every text box's text, so a file is
not changed by opening it, and `check_caps` refuses an over-long source
by its length before reading it, so opening costs nothing more.

Regression tests: `cmd::print::tests::an_equation_source_of_a_megabyte_prints_a_bounded_excerpt`
(a 1 MiB single-line source: the longest text run in the PDF is 4,096
bytes; without the excerpt it is 1,048,576) and
`tests::equation::the_source_excerpt_is_at_most_the_cap_and_whole_chars`.

* Equations: the layout cache answers for a source over the cap without reading it

`EquationCache::get` matched a source by length, then by a hash of the
whole source (`DefaultHasher`), then in full. For a source over
`MAX_SOURCE_BYTES` the entry is always the same error, yet every lookup
still hashed it: the sheet painter asks the cache every frame and the
PDF export every draw, and a source read from a file can be megabytes,
so a hostile file was rescanned on every repaint.

`get` now answers for a source over the cap at once, by its length,
with `gridcraft_math::SOURCE_TOO_LONG` (one shared value, the same error
`check_caps` returns), whatever the entry holds; `holds` and `store`
neither hash nor copy such a source. Within the cap the lookup is
unchanged. Both callers already route anything but `Some(Ok(_))` to the
4,096-byte excerpt, so the painter and the PDF fallback do no work on
the rest of the source. `Workbook::refresh_equations` no longer carries
a branch for over-cap sources: they are never stale, so it never saw
them. Nothing else reads a source per frame: `source_excerpt` slices at
most 4,096 bytes and `check_caps` tests the length first.

Tests: `equation_cache_tests::a_source_of_a_megabyte_is_looked_up_without_being_read`
(a 1 MiB source looked up 1,000 times: no hash, counted by a test-only
counter in `source_key`, and under 0.5 s; on the previous cache it fails
with `left: 1000, right: 0` after 0.20 s) and
`sources_over_the_cap_are_not_copied_hashed_or_laid_out`.

* Equations: the source cap's message says bytes, which is what is counted

check_caps compares the UTF-8 byte length with MAX_SOURCE_BYTES, so 2,049 two-byte characters are refused although the message said 4,096 characters were allowed. The message now names the unit the check uses.

* Equations: ATTRIBUTION.md records Unicode's terms for the Symbol encoding table

The Unicode values of Symbol's character codes in crates/pdf/src/symbol.rs
come from `symbol.txt` ("Adobe Symbol Encoding to Unicode", table version
1.0, 2011-07-12, at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/), which
carries Unicode, Inc.'s own copyright and terms, not Adobe's Core 14
metrics notice: Unicode grants the right to freely use the information
in the file in products supporting the Unicode Standard, and to copy the
file as long as its notice remains attached, as-is and without
warranties. ATTRIBUTION.md now has a row for that table with those
terms, separate from Adobe's row, which keeps to the AFM character codes
and widths; the module comment of symbol.rs credits both.

* Equations: a literal space in text mode reaches Office Math

RaTeX gives the space in `\text{if } x` as a spacing node whose text is
" ", and the OMML writer's spacing match mapped that to "", which `run`
omits, so the saved equation read `ifx`. The match now maps " " to a
space, and a run that starts or ends with whitespace is written with
`xml:space="preserve"` on its `m:t`, so Word keeps it; the reader never
trimmed, so it reads the run back as `\text{ }`.

Tests: `omml::tests::a_literal_space_in_text_mode_is_kept` (the space is
its own marked run in `\text{a b}` and `\text{if } x`; runs without
surrounding whitespace are not marked) and, through the xlsx reader,
`omml::tests::a_space_in_text_mode_survives_the_round_trip` (the same
number of space glyphs before and after). Without the mapping both fail;
the round trip came back as `\text{i}\text{f}x`.

* Equations: the PDF row says what the writer does with fonts and scripts

docs/file-format-parity.md said PDF export used "only the 14 standard
PDF fonts" and that "non-Latin text does not export". That wording
predates this branch, but the branch added the equation note to the same
row, and both halves were wrong against crates/pdf: the writer has 13
standard-font entries (the Helvetica, Times and Courier faces and
Symbol), none embedded, and sets characters outside WinAnsiEncoding in a
predefined, non-embedded CID font (STSong-Light, Adobe-GB1,
`UniGB-UTF16-H`), so Simplified Chinese displays where the reader
supplies that font, while scripts outside Adobe-GB1 export as code
points and show as the reader's missing-glyph box. The row and the
equation revision row now say so, with Symbol for the Greek letters and
operators it has, the Times faces for letters and digits, and `[?]` for
the rest. "Would pass the cap" reads "would exceed the cap" in the
revision row and the matching code comments.

* Equations: the gate checks nesting that macro expansion builds

`check_caps` counts the braces, `\left`s and `\begin`s of the source
text, so nesting that macros build reaches RaTeX, whose recursion guard
is the one that must hold. `nesting_built_by_macros_is_refused_by_the_parser`
builds `\left(`/`\right)` pairs and `\frac` levels through `\def`: 32
levels lay out, 64 are refused with "Recursion limit exceeded", inside
the 1,000-expansion limit, and the wrapper refuses the 64-level source
as well.

* Equations: the description carries a marker of the Office Math, so an edit made in Excel reads back

The xlsx reader took GridCraft's `gridcraft:latex:` description whenever
a shape had one. Excel edits an equation's Office Math and leaves the
description alone, so a file edited there came back as the old equation,
and the next save overwrote the edit.

The writer now puts a marker of the text body it writes into the
description, `gridcraft:latex:<marker>:<source>`: 16 hex digits of an
FNV-1a 64-bit hash (`crate::omml::body_marker`) over what carries the
equation's meaning, the OMML element names in document order and the
text of the runs. It leaves out what a re-serialisation may change
without changing the equation: property elements (`*Pr`), extension
lists, the DrawingML containers, attributes, namespace prefixes and
whitespace between elements; adjacent runs are joined, so a writer that
splits or merges runs changes nothing, while a text change, an added run
or a structural change does. The hash is computed over the parsed body,
the same way on both sides, and FNV is fixed, so the marker survives
Rust and library versions.

The reader takes the source only when the shape holds Office Math and
the marker matches the body now in the file; otherwise it converts the
Office Math with `equation_source` and drops the stale description,
falling back as before when the maths is outside the subset. A body
without Office Math (the plain-text fallback of an equation outside the
subset) keeps the source as it did, and every branch stays
`ShapeKind::Equation`. A description without a well-formed marker is not
GridCraft's source.

Tests: `office_math_edited_elsewhere_reads_back_as_edited` (an `m:t`
changed in the drawing part reads back converted, and a save writes the
edited equation with the old source gone; with the marker ignored it
fails with `left: "x^2", right: "{y}^{2}"`),
`a_marker_over_garbage_office_math_falls_back_without_panicking`,
`omml::tests::the_body_marker_follows_the_equation_not_its_serialisation`,
and the existing round trips, whose exact sources still come back.

* Equations: the PDF fallback text is clipped to the equation frame

The source lines printed for an equation without a layout were drawn at
full width under the block's clip alone, so a long line ran past the
frame over neighbouring content. The lines are now drawn inside a clip
of the frame, saved and restored around the loop, which nests in the
block's clip.

Test: `cmd::print::tests::the_fallback_text_is_clipped_to_the_equation_frame`
(a 40 by 100 px frame gives a `30 75 re W n` clip before the text run,
restored after it); without the clip it fails at "a clip the size of the
frame".

* Equations: the body marker hashes the properties the converter reads

`body_marker` skipped every `*Pr` element, yet `to_latex` reads several
property values: a run's `sty`, `scr` and `nor`, a fraction's `type`, a
radical's `degHide`, an operator's `chr`, `limLoc`, `subHide` and
`supHide`, a delimiter's `begChr`, `sepChr` and `endChr`, an accent's
`chr`, a bar's `pos` and a group character's `chr` and `pos`. An edit in
Excel to one of them (upright to bold, parentheses to brackets, a
hidden limit) left the marker unchanged, so the stale source won.

The properties the converter reads are one table, `PROPERTIES`, that
both sides use: `val` and `on` read a property only when the table lists
it (a read of an unlisted property is absent, which the conversion tests
would show), and `mark` hashes, for each property element, exactly the
listed properties present as `name=val` in the table's order, closing
the run text gathered so far, so the same property moving to another
run is a change too. `ctrlPr`, extension lists and properties the
converter never reads (DrawingML size, colour and fonts) stay out, so
Excel's re-serialisation still leaves the marker alone.

`the_body_marker_follows_the_equation_not_its_serialisation` now checks
that a changed `sty`, a style moved between runs, an accent or delimiter
character, and a hidden operator limit change the marker, while an
added `ctrlPr`, an extension list, DrawingML run properties and an
unread `rFonts` do not. Without the property hashing it fails at the
`sty` case with two equal markers.

* Equations: the clip test checks the graphics state at a later object

The test for the fallback's frame clip counted `q` lines in two ranges
that always added up, and looked for any `Q` after the text, which the
block's own restore supplies, so a missing `pg.restore()` in the
fallback passed it. It now puts a text box below the equation in the
same block and compares the net `q`/`Q` depth at three points: the
text is drawn one level inside the clip's state, and the later object
at the level before it. With the fallback's restore removed it fails
with `left: 2, right: 1` at the later object.

* Equations: the body marker hashes a styled run as a unit, as the converter converts it

The marker joined the text of adjacent runs so that Excel's splitting
and merging of runs would not change it. The converter, though,
converts each run on its own and recognises function names per run: an
upright `sin` becomes `\sin `, while an upright `s` beside `in`, styled
or not, becomes `\mathrm{s}in` or `\mathrm{s}\mathrm{in}`. An edit that
split a styled run therefore kept the marker equal and the stale `\sin `
source was reused.

A run with a property the converter reads is now hashed as a unit: the
property already closed the text gathered before it, and the run's end
closes its own text as well, so a styled run split in two, or a styled
run merged with a plain neighbour, changes the marker. Runs without
read properties convert character by character and stay joined, so
splitting or merging them still changes nothing. This mirrors the
converter exactly, including adjacent runs with identical properties,
which it converts separately.

`the_body_marker_follows_the_equation_not_its_serialisation` now checks
that upright `sin` differs from upright `s` plus plain `in` and from
upright `s` plus upright `in`, that `sin` + `x` differs from `s` +
`inx`, and that plain `ab` equals plain `a` + `b` beside a styled run or
alone. Without the after-run flush it fails at the first new case with
two equal markers.

* Equations: a brace keeps the script on its other side in Office Math

The writer put the label of `\underbrace{x}_{n}` in `m:limLow` and
that of `\overbrace{x}^{n}` in `m:limUpp`, then returned, so a script
on the brace's other side was dropped: `\underbrace{x}_{n}^{2}` lost
the exponent in the Office Math. The source in `descr` hid this in a
GridCraft-to-GridCraft round trip; an application reading the Office
Math saw an incomplete equation.

The other script now wraps the whole: `m:sSup` around `m:limLow` for
an underbrace with an exponent, `m:sSub` around `m:limUpp` for an
overbrace with a subscript. A brace with only the other script
(`\underbrace{x}^{2}`) gets the script around the bare `m:groupChr`,
with no empty `m:lim`; a brace with its label alone, or with neither
script, is written as before. Each wrapper is a level of the depth
accounting.

The reader converted a limit over a brace into `\underset`/`\overset`
around `\underbrace`/`\overbrace`. It now reads a limit whose base is a
brace alone as the brace with the label as its own script, the
writer's inverse, so `\underbrace{x}_{n}^{2}` reads back as
`{\underbrace{x}_{n}}^{2}`; a limit over any other base is still
`\underset` or `\overset`. The body marker hashes element names and
the properties the converter reads, which this does not change: the
stored source is still taken while the marker matches, and an
equation edited elsewhere still reads back as edited.

Tests: `an_underbrace_with_both_scripts_keeps_its_exponent` and
`an_underbrace_without_a_label_keeps_its_superscript` in the math
crate check the exact Office Math for both braces and for nested ones;
`a_labelled_brace_reads_back_with_a_script_on_its_other_side` checks
the reader on hand-written Office Math and the LaTeX round trip (same
glyphs); `a_brace_with_scripts_on_both_sides_round_trips` saves an
xlsx, reads it with the source (taken as written) and without it
(converted, exponent kept). Without the fix the first two fail on the
missing `m:sSup`, the third reads `\underset{n}{\underbrace{x}}`, and
the fourth finds no `m:sSup` in the drawing.

* Objects: main's chart data table compiles with the object selection and the model's object test

Two leftovers of the merge of origin/main (25881a8e), neither a textual
conflict: `Chart` gained a `data_table` field, which the `push_object`
helper of the model's object test did not set (it is `None`, as in
`insert.chart`); and chart_data_table.rs set the `selected_chart` field
this branch replaced by `selected_object`. The test selects its chart as
a floating object, as the branch's other tests do.

* Objects: object.move and chart.set keep a size a file made larger than 10,000

`object.move` clamped `width` and `height` to 10,000 points, the most it
gives a new size, while the XLSX reader accepts anchors far past that.
A resize of a picture a file made 12,000 wide passed 11,990 through the
clamp and jumped to 10,000; `chart.set`, whose Format Chart pane sends
the unchanged dimension back with the edited one, clamped the same way.

Each dimension is now capped at 10,000 or the object's current size,
whichever is larger (`size_cap`): such an object keeps its size when it
is moved, shrinks by what a resize asks and never grows further, and a
hostile size is still capped. The params doc of `object.move` and the
control protocol notes say so.

Tests: an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize
makes a picture 12,000 by 300, writes and reads it back as a file, then
moves it without a size (12,000 stays), asks 12,010 (stays 12,000),
11,990 (11,990, no jump) and 50,000 by 50,000 (11,990 by 10,000).
chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit does the
same through `chart.set`. Both fail without the fix (width 10,000).

* Objects: dragging an object's body sends its position only, not its size

`drag_object` sent `width` and `height` with every step of a body drag,
so moving a picture a file made 12,000 wide shrank it to the 10,000 that
`object.move` gives a new size. Only a handle drag sends a size now; a
body move leaves the engine's size alone.

Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size
reopens the workbook with a 12,000 by 60 picture, drags its body by
30 by 25 and checks the size is untouched, the picture moved with the
pointer and the journaled `object.move` carries no width or height; it
fails without the fix (the journaled command carries both, and without
the engine's cap the picture is 10,000 wide).
a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only
moves the left handle 10 px right: the picture is 10 points narrower in
the window and keeps a width near 12,000, with no jump to the limit.

* Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension

The Width and Height fields ranged 40 to 4,000 and sent both values on
every edit. egui clamps an existing value into a field's range, so a
chart a file made 12,000 wide showed "Width 4000", and typing a new
height sent that 4,000 as the width: the chart shrank although only its
height changed, and the cap `chart.set` keeps for an existing size
cannot help once the pane itself sends 4,000. Each field's range now
reaches the larger of 4,000 and the chart's current size, so the field
shows the real size, and a commit sends only the dimension that differs
from the chart's (`chart.set` reads `width` and `height` independently).

Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes
reopens a workbook with a 12,000-wide chart, opens Format Chart Area's
Size section, checks the Width field shows 12000 and types 201 as the
height: the width stays 12,000, the height is 201 and the journaled
chart.set carries no width. Without the fix the field shows
"Width 4000".

* Objects: arrange and align act on an equation only under its own kind

`arrange.bringForward`, `arrange.sendBackward`, `arrange.align` and
`arrange.group` sent `kind: "equation"`, `kind: "shape"` and any
unknown kind to the unfiltered list of shapes, so a plain shape's id
given as an equation reordered or aligned the plain shape, an unknown
kind acted on shapes, and an id of no object succeeded silently.

The kind is now parsed as the `object.*` commands parse it (`shape`
when absent; an unknown kind is an error with the same message), and
under `equation` only shapes of `ShapeKind::Equation` are matched.
Under `shape` an equation still matches, as `Sheet::object_anchor` has
it for `object.move` and `object.delete`: an equation is a shape too,
and a plain shape is not an equation. `arrange` refuses an id that is
not an object of the kind instead of doing nothing; `align` skips it,
as it skips any id it does not find, so the object is not moved.

`arrange_and_align_match_a_shape_id_to_its_kind` reorders and aligns
with the shape's id given as an equation, the equation's id given as a
shape, an unknown kind and a chart's id given as a shape. Without the
fix it fails at its first assertion: the shape's id under `equation`
is accepted and the shape reordered.

* Equations: a box without Office Math keeps its source only while its marker matches

The reader took the source in an equation's description without
checking the marker whenever the body held no Office Math. An
equation outside the subset (`\begin{CD}`) is written as a plain-text
box; a user who edited that text in another application, which leaves
the description alone as Excel does, got the old source back on
reopen, and the next save wrote over the edit. The check could not
simply be added: the fallback box of an equation with Office Math
carried the marker of the Office Math body, not of its own text, so a
reader left with the fallback alone would have lost the source.

Each box now carries the marker of its own body, the Office Math box
and the plain-text fallback alike, and the reader applies the marker
check to a body without Office Math too: the source while it matches,
else a text box with the edited text. Office Math whose marker does
not match is converted as before.

`a_text_edit_to_an_equation_without_office_math_reads_back_as_the_edit`
writes `\begin{CD} A @>f>> B \end{CD}`, reads it back unchanged, edits
the box's text in the drawing XML and expects a text box with the
edit; it also checks that the two boxes of `x^2` carry different
markers and that the fallback alone still reads as the equation.
Without the fix it fails with the stale source as an equation.
`a_marker_over_garbage_office_math_falls_back_without_panicking` and
`office_math_outside_the_subset_reads_the_fallback` pass unchanged.

* Equations: a box without Office Math keeps its source only while its paragraphs are the writer's

The body marker joins run text across paragraphs on purpose, so that
Excel's splitting and merging of runs changes nothing. A paragraph
split, though, is an edit: another application that split the one
paragraph of a plain-text equation box in two without changing a
character left the marker equal, the stale source was taken on reopen,
and the next save wrote over the edit.

A box without Office Math now takes its stored source only while its
text, paragraphs included, is what the writer wrote for that source:
the writer's paragraphs for the source are built by the same function
and read back through the same parser, then compared paragraph by
paragraph with the body. Office Math bodies keep the marker check
alone, as before.

`a_paragraph_split_in_an_equation_without_office_math_reads_back_as_the_edit`
writes `\begin{CD} A @>f>> B \end{CD}`, splits its paragraph at the
first space in the drawing XML and expects a text box with two lines
and the same characters. Without the fix it reads the equation with
the stale source.

* Objects: arrange and align take the shape default only for an absent kind

`kind_param` read the kind with `str_param`, which gives nothing for a
value that is not a string, so `{"kind": null}` or `{"kind": 7}` fell
back to `shape` and a shape's id was reordered or aligned under an
invalid parameter. The default now applies only when the parameter is
absent; a present value that is not one of the names is refused with
the same message as an unknown name. The `object.*` commands already
refused these values, their kind being required
(`hostile_object_params_are_refused`).

`arrange_refuses_a_kind_that_is_present_but_not_a_name` tries `null`,
a number, an array, an object and a boolean against
`arrange.bringForward`, `arrange.align` and `object.move`, checks that
nothing moved, and that an absent kind still means `shape`. Without
the fix it fails at its first assertion: `null` is accepted.

* Objects: align and group refuse an id that is not an object of the kind

`arrange.align` and `arrange.group` left out an id in `ids` that was
not an object of the kind, or not a number, aligned the rest and
reported success, so a stale or mismatched id succeeded silently, and
the control-protocol sentence that every `arrange.*` command refuses
such an id was not true of them.

Every entry of `ids` must now be an object id (`u32`, not a negative
number, a fraction, a string or null) and name an object of the kind;
otherwise the command is an error and nothing moves, as for `arrange`
and the `object.*` commands. With fewer than two ids the command still
changes nothing and succeeds, which the control-protocol note states.
The Selection Pane does not call these two commands; they are reached
from the control channel and MCP.

`align_refuses_an_id_that_is_not_an_object_of_the_kind` tries a
shape's id under `equation`, an unknown id and four non-ids against
both commands, checks that nothing moved, and that one id or none is a
no-op; `arrange_and_align_match_a_shape_id_to_its_kind` now expects the
mixed list to be refused. Without the fix both fail with the mixed
list accepted.

* Objects: align and group refuse an ids value that is not an array

`arrange.align` read `ids` with `as_array`, so a present value that was
not an array (`null`, a number, a string, an object) counted as no ids
and the command succeeded without doing anything. `arrange.group`
delegates to it. The field may still be absent, which means nothing to
align; a present value must be an array of object ids, and the one
message "ids must be an array of object ids" covers a non-array value
and an entry that is not an object id alike.

`align_refuses_ids_that_are_not_an_array` tries `null`, a number, a
string and an object against both commands, checks that nothing moved,
and that an absent `ids` still succeeds. Without the fix it fails at
its first assertion: `null` is accepted.

* Equations: the paragraph-split test asserts the exact text it reads back

The test's last assertion stripped spaces and newlines from both
sides, so a reader that moved the newline or dropped a space would
have passed it. The fixture cuts the one paragraph after its first
space, which stays in the first paragraph, so the text read back is
the source with a newline after that space; the test now asserts that
text exactly.

* Equations: preserve limits on the opposite side of braces

* Equations: match hosted formatting of brace regression

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-02 15:45:36 +01:00
Ken KölzerandClaude Sonnet 5.5 de254a0a6e Ribbon: Font Size value is centered in its box (fixes #3155)
The size box draws its value with a frameless TextEdit. The theme floors
a text edit at interact_size.y (22 pt), and egui's text edit style aligns
its text to the top of that, which wins over the child layout's
Align::Center. The digits sat 3.5 pt above the middle of the 24 pt box,
4 pt above the Font name's baseline. The text edit now aligns its text
LEFT_CENTER.

The new test checks that the value is centered in its box and on the
Font name label's baseline. It fails without the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 14:41:27 +01:00
Brandon ThomasandClaude Opus 5.5 488f9982eb Review fix for #2729: document the portable shortcut notation and the Mac-only override
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:16:38 -05:00
Brandon Thomas c1bc0980ca Merge PR #2729 2026-11-02 05:08:06 -05:00
prxnvt 5579ec9a65 Merge the updated Python cells branch (7c5f417a, main 3379f12a) into the Python mode layer
Conflicts: control.rs (main's ui.object.select kept beside editor_json), docs/control-protocol.md
(the ui.inspect row keeps this branch's editor text and gains main's selected object; main's
ui.object.select row added), ROADMAP.md and docs/ui-parity.md (header lines combined, both sides'
rows kept).
2026-11-02 04:01:29 -05:00
prxnvt 7c5f417a5d Merge origin/main (3379f12a) into the Python cells branch
Main gained #470 (floating objects) and #501 (Solver Sensitivity report).
Conflicts:
- cmd/edit.rs, edit/paste_special.rs, sheet/tabs.rs: #470 adds rewrite_cell_links for
  chart references beside this branch's rewrite_python_refs; every structural edit,
  sheet rename and sheet delete runs both, the Python one last where it can refuse
  the edit (the edit then changes nothing).
- cmd/sheet.rs: rewrite() keeps this branch's Result with main's doc comment.
- model/src/sheet.rs and engine tests.rs: both sides' additions kept.
- ROADMAP.md: header combined, both sides' rows kept.
Main's new code writes no cells, so no Python handling was needed beyond these.
2026-11-02 04:01:07 -05:00
PranavandFredApps 3379f12a56 Data: Solver Sensitivity report (#501)
* Data: Solver Sensitivity report

Builds on the Answer and Limits reports. reports: [sensitivity] (on
data.solver and data.solverReport) writes a Sensitivity report for
models solved by Simplex LP or GRG Nonlinear without integer
constraints and with a Max or Min objective.

Simplex LP: the model is linearised at the solution and solved again,
and the report is read from the final tableau: each variable's reduced
cost and the range of its objective coefficient over which the basis
stays optimal, and each constraint's shadow price and the range of its
right side over which that price holds (1E+30 for no limit).

GRG Nonlinear: difference gradients at the solution give each binding
constraint's Lagrange multiplier (a least-squares fit on the variables
inside their bounds, dropping constraints whose multiplier has the
wrong sign) and each variable's reduced gradient.

The Solver Results dialog offers the report.

* Data: Solver Results offers the Sensitivity report only where it applies

Integer and binary models, the Evolutionary method and Value Of goals
have no Sensitivity report, and choosing it with other reports made the
whole report request fail. Solver Results now disables the Sensitivity
choice for such models (with the reason on hover) and leaves it out of
the request, so the other chosen reports are still written.

sensitivity_applies (in cmd::solver) reads the model from data.solver
params, as the dialog holds them; a test checks that it agrees with the
report for each kind of model.

* Data: Solver bounds the nonlinear Sensitivity report's work

The Lagrange multipliers of a GRG Nonlinear model are found by least
squares over the binding constraints; a constraint whose multiplier has
the wrong sign is dropped and the rest are solved again. Each pass costs
about 2·k²·f operations for k binding constraints and f free variables,
and each pass drops at most one constraint, so a model at the
1,000-constraint limit could take minutes and hold the window.

The passes now share a limit of about 10⁹ operations (about a second).
Past it the report is refused with a message, before anything changes:
the workbook and the Undo history stay as they were, as for the other
report refusals. The limit is lowered in tests so the refusal can be
reached with a small model.

* Data: Solver refuses a Simplex Sensitivity report for a point that is not optimal

The Simplex LP Sensitivity report solves the linearised model again to
read its final tableau, but shows the final values it was given.
data.solverReport accepts any values, so a report could pair values that
are not optimal (or not feasible) with the shadow prices, reduced costs
and ranges of the true optimum. The report now checks that the given
point satisfies every constraint and bound and reaches the optimal
objective (both within the constraint precision; another optimal point
with the same objective is accepted) and is refused otherwise, before
anything changes.

* Data: Solver compares the Sensitivity report's objective in both directions

The check that a point is optimal compared its objective with the
optimum in one direction only, as for an inequality. A point just
outside a bound (within the constraint precision) can still have an
objective far below the minimum when its costs are large, and that
point was accepted. The objective is now compared by the size of the
difference, so such a point is refused.

* Data: Solver refuses a nonlinear Sensitivity report for a point that is not optimal

data.solverReport accepts any finite variable values. The Simplex LP
report already refuses values that are not optimal, but the GRG
Nonlinear report described whatever point it was given: for example, a
minimisation of A1^2 given A1 = 1 found no binding rows and reported a
reduced gradient of 2 instead of requiring the optimum at A1 = 0.

The nonlinear report now checks, from the gradients it already
computes, that the values are a Karush-Kuhn-Tucker point before
anything changes: every constraint holds within the tolerance the
nonlinear method accepts a solution within, every bound holds within
the constraint precision, each variable inside its bounds has a reduced
gradient near zero, and a variable at a bound has a reduced gradient
that points only out of that bound for the objective's direction. A
binding row whose multiplier has the wrong sign is still dropped, and
the gradient it would have explained now causes the refusal. The
stationarity tolerance is 1E-3 of the largest objective-gradient
component (at least 1), well above the reduced gradients left at optima
the method solves.

* Data: Solver writes the other reports when the Sensitivity report does not apply to the solution

Since the nonlinear Sensitivity report refuses values that are not
optimal, data.solver with reports that include sensitivity failed as a
whole when the run stopped before an optimum (an evaluation limit) or
the values were not one, so the solution and the Answer and Limits
reports were lost. Solver Results also offered Sensitivity after any
solve, and ticking it with Answer after such a run wrote no report.

data.solver now writes the Sensitivity report only when the run reached
an optimal or converged solution that the report accepts. Otherwise the
solve still applies, the other reports are written, and the result
lists the report in skippedReports with the reason. Models that have no
Sensitivity report (integer, Evolutionary, Value Of) are refused as
before. data.solverReport still refuses values that are not optimal;
its new skipNotOptimal parameter leaves the report out instead. Solver
Results offers Sensitivity only after an optimal or converged status,
with the reason on hover, passes skipNotOptimal, and shows the reason
when the report is left out.

* Data: Solver skips a Sensitivity report in a protected workbook without refusing the call

Structure protection is now checked only when a report sheet will be written. A data.solverReport request with skipNotOptimal whose only report is skipped changes nothing, so it succeeds in a workbook whose structure is protected and returns skippedReports; a request that would write a sheet is still refused.

* Data: Solver refuses report sheets in a protected workbook before building them

A request that must write a report sheet (any Answer or Limits report, or a Sensitivity report that is not allowed to be skipped) is refused before any report is built, so a protected workbook does not run the Limits report's optimisations first. The check after the reports are built remains for a Sensitivity report that may be skipped.

* Data: Solver reports Simplex sensitivity only at the vertex it describes

The Simplex LP Sensitivity report solves the linearised model again and
takes its prices and ranges from that final tableau, but it discarded
the vertex the solve returned and showed the values it was given. When
the optimum is not unique, another optimal point passed the optimality
check, so the report paired those values with the ranges of a different
vertex. The report now also requires the given values to match the
re-solved vertex within the constraint precision, and refuses another
optimum with a message that says so. A Sensitivity report that data.solver
or Solver Results may skip is left out instead, as for other points the
report does not support. Values from a Simplex LP solve still match, so
the usual Solver Results flow is unchanged.

* Data: Solver gives a redundant constraint's right side no allowable change

The Simplex LP Sensitivity report ranges each right side over the basic
values of the real columns only. A row that the other rows make
redundant (max x with x = 2 and 2x = 4) keeps its artificial column
basic at zero after phase one, and changing either right side alone
moves that artificial, so no change has a solution. The report showed
1E+30 and 2 for the first row instead. When a basic artificial has a
nonzero entry in the column of the right side being changed, both
allowable changes are now 0. The objective-coefficient ranges are
unaffected: such a row has no cost and no entries in the real columns,
and a cost change does not affect feasibility.

* Data: Solver builds the Simplex Sensitivity report's program at the solve's start values

The Simplex LP solve linearises the model at the start values, but the
Sensitivity report linearised it again at the solution. The difference
coefficients then differ in their last bits, and when the optimum is
not unique that can break a tie between pivots the other way: the
report's solve returned the other vertex and refused the solve's own
values. With max 0.1x + 0.1y over a tied edge, more than half of the
sampled starting points were refused.

The solve and the report now build the program with one function,
simplex_program, and the report builds it from the start values it is
given (data.solver's start values, which Solver Results passes back as
original), so its solve repeats the solve's pivots and returns the same
vertex. Start values at which the model cannot be evaluated fall back
to the solution, and values that are not the solve's are still refused
by the vertex check.

* Data: Solver's Sensitivity report sets its noise levels from the coefficients, not the objective's value

The figures a Sensitivity report prints as 0 were those below 1E-9 of the objective's value (1E-6 for the nonlinear figures), so a constant in the objective hid real prices: max 1E9 + x with x <= 10 wrote the row's shadow price, exactly 1, as 0, although the constant changes nothing about the optimisation. A shadow price and a reduced cost are combinations of the objective coefficients, so Simplex LP now takes the noise level from the largest coefficient; a reduced gradient is a part of the objective's gradient and a Lagrange multiplier objective per unit of its row's gradient, so GRG Nonlinear takes it from the gradient's size, and the multipliers' sign test compares lambda times the row's gradient size with it. Allowable changes keep the tableau's pivot tolerance as before.

* Data: Solver Sensitivity report tests for variables at a bound and for both goals

Hand-derived cases pin the report's conventions. Simplex LP: a maximum's variable at its upper bound (reduced cost 1, Allowable Increase 1E+30, Allowable Decrease 1), a minimum with a >= row and a variable at its upper bound (reduced cost -1, Allowable Increase 1, Allowable Decrease 1E+30), and Winston's Dakota Furniture problem, whose variable at zero has reduced cost -5 with Allowable Increase 5 and Allowable Decrease 1E+30, with every shadow price and allowable range worked out from the final basis. GRG Nonlinear: the Lagrange multiplier is the derivative of the optimal objective with respect to the right side and the reduced gradient that with respect to the variable's bound for a minimum (-1) and the negated maximum (+1), and the mirror image of the existing minimum with a variable at its bound (multiplier +2, reduced gradient -4).

* Docs: ROADMAP.md records the Solver Sensitivity report

The detail documents (docs/gaps.md, docs/target-app-parity.md) record the report, but the roadmap summary did not: its header change note, progress log and revision history now carry it.

* Data: Solver's Sensitivity report bounds each figure's rounding noise by its own terms, not the model's largest coefficient or gradient

The noise level a figure had to exceed to print was 1E-9 of the largest objective coefficient (Simplex LP) or 1E-6 of the largest gradient component (GRG Nonlinear) for every figure, so an unrelated large coefficient hid real values: max 1E9*x + y with x <= 1 and y <= 1 as rows wrote the second row's shadow price, exactly 1, as 0, and the same model's Lagrange multiplier 1 and a third variable's reduced cost or gradient -1 went the same way. The rounding error of a sum scales with the sum of its terms' sizes, so each figure now has its own bound from the terms it is made of. Simplex LP: a shadow price y_i = sum over the basis rows of c_B(r) * Binv(r, i) is bounded by 1E-9 of those terms' sizes (the row's unit column of the final tableau holds Binv's column i); a reduced cost cf_j - sum of a_ij * y_i by 1E-9 of |cf_j| plus |a_ij| times each price's term sizes, so a price that is a small difference of large terms carries their error into the reduced costs it is used in. GRG Nonlinear: a multiplier solved from its row of the normal equations is bounded by 1E-6 of its own terms (the row's gradient times the objective's, and its coupling terms with the other active rows times their multipliers) over the row's squared norm; the multipliers' sign test uses the same bound, so a row leaves the active set on the criterion the report rounds with; a reduced gradient is bounded by 1E-6 of its own component and the multipliers' terms plus the multipliers' bounds times |g_ij|. Nearly parallel active gradients amplify every multiplier's error beyond a bound local to a row; such multipliers print as computed. A constant in the objective still changes no bound, since none depends on the objective's value. The nonlinear method stops on the 1E9 model from (0, 0) with "unbounded", so its two regression tests ask for the report at the optimum through data.solverReport.

* Data: Solver's Sensitivity report tests a nonlinear point's optimality by each variable's and row's own scale, not the model's largest

The Karush-Kuhn-Tucker check that refuses a Sensitivity report for a point that is not optimal accepted a reduced gradient within 1E-3 of the largest gradient component, and a row violation within ten times the constraint precision of the largest constant right side. Both scales were the model's, not the figure's: on max 1E9*x + y with x <= 1 and y <= 1 as rows the tolerance was 1E6, so (1, 0.5), where raising y gains 1 per unit, passed as optimal and the report was written for it; on max y - (x - 2)^2 with x <= 1E9 and y <= 1, (2, 1.0001) passed although the second row is over by 1E-4 against its own precision of 2E-5. Each reduced gradient is now tested against 1E-3 of the terms it is made of (the objective's component and the multipliers' parts, at least 1, the same floor as before), and each row against ten times the constraint precision of its own right side. The nonlinear method itself accepts a solution relative to the largest constant right side; a row over by more than its own precision is not satisfied whatever the other rows' sizes, so the report refuses it. The term sizes are shared with the reduced gradients' rounding bound. Two regression tests, each proving the workbook is left as it was and that the optimum is still reported.

* Data: Solver refuses a Sensitivity report the model has none of before any report is built or the solve runs

The refusals that apply to the model rather than the solution (integer or binary constraints, the Evolutionary method, a Value Of goal) were made inside the report loop, when it reached the Sensitivity report, so a request listing the Limits or Answer report first built those reports for an error: a Value Of model asking for limits and sensitivity ran the Limits report's two optimisations per variable and received only the refusal, and data.solver ran its whole solve before it. prepare_reports now checks the model's refusal before the loop, in both modes (a refusal of the model was already an error in both; only the solution's not-optimal refusal is skipped under skipNotOptimal), and data.solver checks it before the solve. The regression test asks for limits and sensitivity on an integer model, which now gets the Sensitivity refusal instead of the Limits report's, and on a Value Of model, which gets the Sensitivity refusal with no sheet written.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-02 09:46:01 +01:00
prxnvt f988c2843e Merge the updated Python cells branch (9f4ca54e) into the Python mode layer 2026-11-02 03:34:07 -05:00
prxnvt 9f4ca54e9a Engine: Python code counts as content where a cell's content is checked
A Python cell whose cached value is empty (a file's PY formula with no cached
value) was taken for an empty cell by six checks, which use the value and the
formula only:
- the Analysis ToolPak report and the PivotTable report replaced it without
  asking for overwrite or replace (CodeRabbit, on the report);
- Insert Cells and Insert Table without headers pushed it off the end of the
  sheet;
- the data region (Ctrl+A, AutoSum, sort) ended at it;
- pasting it into part of a legacy array was not refused.
They now use Cell::has_content, which counts Python code.

Test: python_code_with_an_empty_value_counts_as_content; reverting any one of
the six changes makes it fail.
2026-11-02 03:33:45 -05:00
Pranav f6a0556b9e Objects: one selection for every floating object, eight resize handles, and objects that follow edits (#470)
* Objects: one selection for charts, pictures, shapes and slicers, with eight resize handles

The sheet tracked the selected object as a bare id in `selected_chart`, so
only charts and text boxes drew a selection frame, only the bottom-right
corner resized, and the kind of the selected object had to be guessed from
the id. The selection is now `SheetApp::selected_object: Option<ObjRef>`,
with `selected_chart()` kept for the Chart Design tab and the Format Chart
pane.

- Model: `ObjKind` (chart, image, shape, slicer) and `ObjRef { kind, id }`;
  `Sheet` helpers find, move and remove an object by `ObjRef` and walk every
  object's anchor in a fixed order. `next_object_id` saturates instead of
  overflowing on an id at `u32::MAX` read from a file.
- Commands: `object.delete`, `object.move` and `object.setAnchorMode` accept
  the four kinds through one parameter check, refuse an unknown kind or id
  instead of succeeding silently, and clamp hostile offsets. `object.delete`
  replies with the deleted object. Arrange and the Selection Pane include
  slicers, which stack above the other objects.
- Sheet: the selected object of any kind draws a frame with eight handles
  over every object. Each handle resizes only its own edges, which never
  cross; the body moves the object, and the first drag frame already applies
  the pointer's movement. The pointer shows the handle's resize cursor or a
  move cursor, with the diagonals swapped on a right-to-left sheet, where the
  frame moves with its object.
- Keys: Escape drops the object selection; Delete and Backspace keep
  deleting the selected object (#213). A selection whose object disappears
  (undo, redo, delete, sheet switch) is cleared.
- Double-click: `open_object` routes by kind (a text box edits in place, a
  chart opens Format Chart).
- Control protocol: `ui.inspect` reports `selectedObject {kind, id}` and
  keeps `selectedChart`; `ui.object.select` selects any object and replies
  with its rect. The snapshot example takes `--object <id>`.

Tests: engine tests for every kind by kind and id, the Selection Pane and
hostile parameters; UI tests for selecting each kind (a slicer over a chart
takes the click), all eight handles on four kinds, cursors, Delete,
Backspace and Escape, undo and redo, double-click routing, and resizing on a
right-to-left sheet. Existing tests use the new selection API.

* Objects: every kind follows edits; chart series follow cut-moves and sheet deletes

Structural edits and sorts kept three hand-indexed lists (charts, images,
shapes) in step in two places, and slicers were left out of both, so a
slicer stayed put when rows were inserted above it. Chart series were
rewritten for inserted and deleted lines and for sheet renames, but not for
cut-moves or sheet deletes, which left them pointing at the old cells.

- Inserting and deleting rows and columns and sorting walk
  `Sheet::object_anchors` and `object_anchors_mut`, one fixed order over
  every kind, so slicers now follow their cells by their anchor mode like
  the other objects, and a new kind cannot be forgotten in one of the
  passes.
- `rewrite_cell_links` is the one place that rewrites the references
  floating objects hold (chart series values, categories and names,
  and the source block a chart was made from).
  `rewrite_all_formulas` calls it, which covers inserting and deleting
  lines and cells and cut-moves on one sheet or across sheets; renaming and
  deleting a sheet call it too, so a series into a deleted sheet becomes
  #REF!, as a formula does.
- Copying a sheet gives its charts, pictures, shapes and slicers fresh ids,
  keeping ids unique across the workbook, which object selection and the
  `{kind, id}` commands rely on.

Tests: every kind through row and column inserts and deletes, an absolute
shape, a sort and undo; a slicer through inserted rows; chart series
through a cut-move, a sheet rename, a sheet delete and undo; ids after a
sheet copy.

* Objects: bubble sizes follow edits like the other series references

`rewrite_cell_links` rewrote a chart series' values, categories and name
but not its bubble sizes, so inserting or deleting lines, a cut-move or a
sheet rename left a bubble chart's sizes pointing at the old cells. They
are now rewritten with the rest.

Test: bubble sizes through an inserted row, a cut-move and a sheet rename.

* Objects: a resize handle under the headers leaves the press to them

A selected object's handles reach a few points outside it, and a press
was matched against them before any check of where it landed, so a press
on the column or row header beside an object at the sheet's edge resized
the object instead of selecting the column or row. The cursor already
ignored handles outside the cells.

`chartview::selected_handle` finds the selected object's handle under a
point only over the cell area, as `hit` does, and not while objects are
hidden; the press and the cursor both use it.

Test: presses within a handle's reach over the column header and the row
header neither move nor resize the object.

* Objects: each drag of an object is its own undo step

A drag merges its frames into one undo step by dropping a new step whose
label matches the one before it. Its first frame did the same, so a second
drag of an object (a move after a move, or a resize after a move) merged
into the first drag's "Move Object" step, and one undo took back both.

`Drag::Object` records whether the drag has made its step; only the later
frames of the same drag merge into it.

Test: a move and then a resize of a picture take two undos to reverse,
the first restoring the moved picture.

* Objects: the parity and gap docs no longer list chart resizing as missing

gaps.md (§4), target-app-parity.md (the Essentials tier's work, the
Mainstream chart row, the interaction discount and the Essentials chart
task) and chart-parity.md (the element table and the work list) still
described charts as impossible to resize (#190). Charts move and resize
from eight handles like every floating object, so those statements now
say so; the 2026-10-10 revision rows and the 10-10 re-check stay as
history. Scores and the 40-60 h chart estimate are unchanged (resizing
was 1-2 h, within the rounding).

* Objects: one chart evidence paragraph in gaps.md; trendlines are not missing

gaps.md §4 carried two evidence paragraphs, an older one saying PRs #158
and #161 were open and a newer one saying all 2-D kinds survive XLSX round
trip, both repeating the resize, smooth-line, Switch Row/Column and
trendline statements. It is one paragraph with the current status again.

The Mainstream chart row in target-app-parity.md listed trendlines as
missing; they are read, written and drawn (`Series::trendlines`, the XLSX
chart reader and writer, `chart.addElement`), as chart-parity.md records.
The row now says so and keeps "no axis options". No score change.

* Objects: only the selected object's handles resize; a press elsewhere on an object moves it

A primary drag looked for a handle under the press on whatever object it
grabbed, so pressing near the edge or corner of an object that was not
selected started a resize, while the pointer over that spot showed the
Move cursor (handles are only shown, and only offered by the cursor, on
the selected object). The press now takes its handle from
`chartview::selected_handle`, the same lookup the cursor uses; a press on
any other object selects and moves it.

Test: dragging an unselected shape by its bottom-right corner shows the
Move cursor, then selects and moves the shape without resizing it.

* Docs: ui.object.select says what it replies when it clears the selection

* Objects: a test pins that both anchor accessors visit the same objects in the same order

Sorting and row or column edits read every anchor through
`object_anchors`, compute the new ones and write them back through
`object_anchors_mut` in step. Nothing checked that the two accessors
visit the same objects in the same order; a kind of floating object
reaching only one of them would have paired objects with other objects'
anchors silently.

Test: two objects of every kind are inserted in a mixed order, each
anchor is marked with its position through the mutable accessor, the
marks are read back through the shared one in the same sequence, the
count matches the per-kind collections, and the mark is found on the
object `object_anchor` names by kind and id. The test's constructor
matches on `ObjKind` exhaustively, so a new kind does not compile until
the test covers it.

* Objects: sorting keeps the size of a "move and size" picture whose lines swap places

A picture anchored to move and size with cells and spanning two rows
(or columns) was re-fitted between its top-left and bottom-right lines
after a sort. When the sort put the bottom line above the top one, the
fitted extent was negative and the picture collapsed to a zero height
or width. It now keeps its old size on that axis and travels with its
top-left line, like a "move only" picture.

Tests: for a sort by rows and one by columns, "move and size" and
"move only" pictures inside one line travel with it (the first
re-fitted to the same line, the second at its own size), an absolute
picture stays while the cells move under it, a two-line picture whose
lines swap keeps its size, and undo restores every anchor.

* Objects: a copied sheet's objects get free ids even when a file used the largest id

`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a file can carry an object
with that id; every later insert (a chart, picture, shape, table or
slicer) then got `u32::MAX` again, and so did each object of a copied
sheet, whose ids also came from a saturating counter. Lookups and
selection take the first object with an id, and `object.delete` removes
every one.

`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead,
and a new one covers a file that used the largest ids: the free ids come
from below, 100,000 of them distinct and in order.

Test: object_ids_stay_unique_when_a_file_used_the_largest_id gives a
shape id u32::MAX, inserts two shapes and copies the sheet; every shape id
is distinct across both sheets. It fails without the fix (both new shapes
get 4294967295).

* Objects: main's chart data table compiles with the object selection and the model's object test

Two leftovers of the merge of origin/main (25881a8e), neither a textual
conflict: `Chart` gained a `data_table` field, which the `push_object`
helper of the model's object test did not set (it is `None`, as in
`insert.chart`); and chart_data_table.rs set the `selected_chart` field
this branch replaced by `selected_object`. The test selects its chart as
a floating object, as the branch's other tests do.

* Objects: object.move and chart.set keep a size a file made larger than 10,000

`object.move` clamped `width` and `height` to 10,000 points, the most it
gives a new size, while the XLSX reader accepts anchors far past that.
A resize of a picture a file made 12,000 wide passed 11,990 through the
clamp and jumped to 10,000; `chart.set`, whose Format Chart pane sends
the unchanged dimension back with the edited one, clamped the same way.

Each dimension is now capped at 10,000 or the object's current size,
whichever is larger (`size_cap`): such an object keeps its size when it
is moved, shrinks by what a resize asks and never grows further, and a
hostile size is still capped. The params doc of `object.move` and the
control protocol notes say so.

Tests: an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize
makes a picture 12,000 by 300, writes and reads it back as a file, then
moves it without a size (12,000 stays), asks 12,010 (stays 12,000),
11,990 (11,990, no jump) and 50,000 by 50,000 (11,990 by 10,000).
chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit does the
same through `chart.set`. Both fail without the fix (width 10,000).

* Objects: dragging an object's body sends its position only, not its size

`drag_object` sent `width` and `height` with every step of a body drag,
so moving a picture a file made 12,000 wide shrank it to the 10,000 that
`object.move` gives a new size. Only a handle drag sends a size now; a
body move leaves the engine's size alone.

Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size
reopens the workbook with a 12,000 by 60 picture, drags its body by
30 by 25 and checks the size is untouched, the picture moved with the
pointer and the journaled `object.move` carries no width or height; it
fails without the fix (the journaled command carries both, and without
the engine's cap the picture is 10,000 wide).
a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only
moves the left handle 10 px right: the picture is 10 points narrower in
the window and keeps a width near 12,000, with no jump to the limit.

* Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension

The Width and Height fields ranged 40 to 4,000 and sent both values on
every edit. egui clamps an existing value into a field's range, so a
chart a file made 12,000 wide showed "Width 4000", and typing a new
height sent that 4,000 as the width: the chart shrank although only its
height changed, and the cap `chart.set` keeps for an existing size
cannot help once the pane itself sends 4,000. Each field's range now
reaches the larger of 4,000 and the chart's current size, so the field
shows the real size, and a commit sends only the dimension that differs
from the chart's (`chart.set` reads `width` and `height` independently).

Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes
reopens a workbook with a 12,000-wide chart, opens Format Chart Area's
Size section, checks the Width field shows 12000 and types 201 as the
height: the width stays 12,000, the height is 201 and the journaled
chart.set carries no width. Without the fix the field shows
"Width 4000".
2026-11-02 09:30:13 +01:00
prxnvt 94b292d5a8 UI: a refused ui.pythonMode start is an error with the reason
ui.pythonMode {on: true} (or a switch with Python mode off) answered {"on": false}
when Python mode could not start, so a control or MCP client saw success. It now
returns an error naming why: a locked cell on a protected sheet, a cell or draft
inside another cell's spill, or code hidden on a protected sheet.

Test: a_refused_ui_python_mode_start_is_an_error_with_the_reason (fails without
the fix); python_mode_starts_no_edit_on_hidden_code and
python_mode_does_not_start_on_a_draft_typed_inside_a_spill now expect the error.
2026-11-02 02:52:48 -05:00
prxnvt 23e09713a9 Docs: ui.inspect shows a draft typed over a hidden Python cell 2026-11-02 02:52:48 -05:00
prxnvt 5119fb5f51 UI: Python mode starts on no draft typed inside a spill
Python mode refuses a cell inside another cell's spill, but a draft typed on a spill child could
still switch to it with Ctrl+Alt+Shift+P, the PY badge, ui.pythonMode or =PY then Tab, and
Ctrl+Enter then wrote code into the spill. toggle_python_mode and the formula bar's switch now
check the draft's own cell (draft_in_spill) and keep such a draft an ordinary entry.

Tests: python_mode_does_not_start_on_a_draft_typed_inside_a_spill (control) and
a_draft_typed_inside_a_spill_does_not_enter_python_mode (keys); both fail without the fix.
2026-11-02 02:52:48 -05:00
prxnvt b5acb39356 UI: a draft typed over a hidden Python cell stays open
An ordinary draft typed over an unlocked, hidden Python cell on a protected sheet holds only what
was typed. follow_open_draft dropped it after any command, and ui.inspect hid its text, because
both checked whether the cell was a Python cell. They now check whether the draft holds code: a
draft in Python mode, or one that began on a Python cell's code (py_cell).

Test: a_draft_typed_over_a_hidden_python_cell_stays_open_through_commands (fails without the fix:
the draft was dropped).
2026-11-02 02:52:48 -05:00
prxnvt 9a094696fe Merge the updated Python cells branch (57d7ea9b, main 25881a8e) into the Python mode layer
Conflicts: theme.rs (main's trace-arrow tokens kept beside the Python tokens), ROADMAP.md and docs/ui-parity.md (header lines combined, both sides' rows kept).
2026-11-02 02:52:48 -05:00
prxnvt 57d7ea9bc3 Engine: a Python reference that grows or shrinks drops the cell's kept result
Deleting part of a range that xl() reads (row 2 of A1:A3) shortens the
reference instead of making it #REF!, and inserting rows inside the range
lengthens it; either way the data the code reads changed, but the cell kept
its result. rewrite_python_refs now treats a reference whose rows or columns
changed in number like a deleted one: the cell is new code (#BLOCKED!) with no
kept result. A reference that only moves keeps the result.

Test: deleting_the_data_python_code_reads_drops_its_kept_result now covers a
partial deletion and an insertion inside the range (fails without the fix).
2026-11-02 02:52:38 -05:00
prxnvt e8f2becf4a Engine: a deleted reference drops a Python cell's kept result; spills read past the scan budget stay values
A structural edit that deleted the data an xl() reference names rewrote the
reference to #REF! but kept the cell's cached result, so the cell went on
showing a result for data that is gone. rewrite_python_refs now makes such a
cell new code (#BLOCKED!, no kept result), and the recalculation removes its
spill. A rename or a move, after which the reference names the same data,
still keeps the result.

When the reader's range-scan budget ran out part way through a Python spill,
the rest of its cells were read as plain cells, and the result rebuilt on open
was then blocked by them (#SPILL!). On such a sheet every Python spill now
keeps its values as plain values, with no live spill.

Tests: deleting_the_data_python_code_reads_drops_its_kept_result and
python_spills_read_past_the_scan_budget_keep_their_values_as_plain_values;
both fail without the fix.
2026-11-02 02:27:28 -05:00
prxnvt ad6aca08bf XLSX: a Python spill is recorded at its anchor, and one too large keeps its values
A Python cell's spill range was recorded only when the file held a record for
one of its other cells, so a result whose other cells are blank, which a file
may leave out, opened with no spill and no rebuilt result. The range is now
recorded when the anchor is read, if a result can fill it.

A Python range larger than a result may be (more than 1,000,000 cells, but
within what the reader tracks) emptied the cells it covered, although it is
no spill and file-format-parity.md says such cells keep the values the file
shows. They are now kept as plain values; a formula's spilled values are still
dropped, since recalculation fills them again.

Tests: a_python_spill_whose_blank_cells_have_no_records_is_still_a_spill and
a_python_spill_range_larger_than_a_result_keeps_its_values_as_plain_values;
both fail without the fix.
2026-11-02 02:02:07 -05:00
AnhNgoo b408b70986 fix(ui): preserve alignment shortcuts for CLI consumers 2026-11-02 13:43:29 +07:00
prxnvt b78afc9b3c Merge origin/main (25881a8e) into the Python cells branch
Conflicts: ROADMAP.md and docs/file-format-parity.md only (header lines combined, both sides' rows kept).
2026-11-02 01:29:06 -05:00
AnhNgoo e945d7b76d fix(ui): preserve portable shortcuts and macOS overrides 2026-11-02 13:28:18 +07:00
PranavandFredApps 25881a8e03 UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes (#492)
* UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes

Closing a workbook (File > Close, Cmd+W, the Backstage Close, a window's
close button) while the macro editor holds unsaved changes now asks about
the draft first, with the editor's own Save changes to macro question,
even if the workbook itself is saved. Before, the workbook closed and the
draft was discarded without a question. Save or Don't Save goes on to
close the workbook, which then asks about its own unsaved changes (a
saved macro is one); Cancel keeps both open. A draft that cannot be
saved keeps its workbook open with the reason shown. A new macro that
has not been edited does not ask, and a close that leaves the workbook
open (one of several windows) or a forced close goes ahead as before.

* UI: a new macro's name change alone counts as an unsaved macro-editor draft

The check compared the draft with an untouched new macro that took the
draft's current name, so a new macro whose only change was its name
compared equal, and closing its workbook discarded the name without
asking. It now compares with the name the editor opened with.

* UI: Quit asks about an unsaved macro-editor draft before quitting

Quit (Cmd+Q, the macOS Quit menu, a quit from the Dock) went straight to
the unsaved workbooks and never consulted the macro step editor, so with
every workbook saved the app quit and discarded the editor's unsaved
steps. It now lets the editor ask first, as the window's close button
already does: Save or Don't Save goes on with the quit, Cancel stops it.

With one native window per workbook, the editor goes on with the app's
quit directly once it has closed: a close sent to the window it is drawn
in would close only that workbook's window.

* UI: a refused macro save while quitting stops the quit

When the macro editor's Save changes question was answered Save during
a quit (the window's close button or Quit) and the save was refused (a
new macro with a name already in use, a limit), the editor stayed open
with the reason shown but kept its quit intent. Closing the editor later
on its own then quit the app. A refused save now stops the quit, as
Cancel does.

* UI: a quit takes over a workbook close waiting on the macro editor

When File > Close was waiting on the macro editor's question and Quit
came in (or the other way round), closing the editor went on with the
quit and then replayed the close. With one native window per workbook,
the quit had already moved to the next unsaved workbook's window, so the
replayed close acted on that workbook and replaced the quit's Save
Changes question with an ordinary close question. The quit now takes
over: it asks about every unsaved workbook itself, and the waiting close
is dropped.

* UI: an untouched new macro closes without asking, however the editor closes

Closing a workbook already left an untouched new macro alone, but the
editor's own close checks still read its dirty flag, which a new macro
starts with: its close button, a quit, the control channel's cancel and
the close after a stopped debug run asked Save changes to macro (or
refused) for a macro nobody had edited. Every close check now asks
whether the draft has unsaved changes, so all of them agree.

* UI: closing the window that shows the macro editor asks about its draft

The editor's unsaved draft was consulted only when its workbook would
close. Closing the window that shows the editor while the workbook
stayed open in another window (window.close, or File > Close in that
window) went ahead: the window's dialog went with it, and the editor's
state was left behind with no window to show it, so the next macro
opened in the editor replaced the draft without asking. That close now
asks first, like a workbook close.

An editor left with no window to show it (a saved one, or a forced
close) is now dropped, as when its workbook closes. A window that has
closed no longer counts as showing a dialog while its parked state is
still kept.

* UI: a close waiting on the macro editor runs in the window it was asked in

A workbook or window close that waited on the editor's Save changes
question was replayed with the command and its parameters alone, so
file.close acted on whichever window was the session's active one when
the editor closed. The editor is drawn with its own window active, but
nothing tied the replay to it. The pending close now records the
editor's window and enters it before running again; if that window has
closed meanwhile, nothing else is closed and a message says so.

* UI: a close asked while the macro editor is asking waits for the answer

A second file.close or window.close for the editor's workbook or window
while its Save changes question stood replaced the pending close with
the new one: the answer then closed whatever the last request asked for,
not what the question had been opened for. A further request for the
same close now waits on the open question (no second question, and the
close runs once); a request for a different close (the workbook while
only the editor's window was closing, or the reverse) is refused with
the reason until the question is answered. A quit keeps taking over as
before.

* UI: test the quit with a macro draft in one window and unsaved cells in another

The quit asks about the editor's draft first, in its window, then about
each unsaved workbook in its window; Cancel at either question stops it
with every window and workbook open, and a draft discarded with Don't
Save before the quit was cancelled stays discarded. Covered in the
single-window harness, which presses the real buttons, and in the
native-windows harness, which now takes a key press in the root window.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-02 07:04:10 +01:00
PranavandFredApps aedbbba896 Data: Analysis ToolPak two-factor Anova, z-Test, Fourier Analysis, Random Number Generation and Sampling (#495)
* Data: Analysis ToolPak two-factor Anova (with and without replication) and z-Test

Builds on the ToolPak of #320. data.analysisToolPak gains three tools:
Anova: Two-Factor Without Replication (rows and columns as factors, the
residual as the error term), Anova: Two-Factor With Replication (samples of
rowsPerSample rows, with the interaction and within-cell terms) and z-Test:
Two Sample for Means (known variances, normal tails and critical values from
NORM.S.DIST and NORM.S.INV). Each has a form in the Data Analysis dialog.
Tests check the reports against Montgomery's hardness-testing and
battery-life examples.

* Data: Analysis ToolPak Fourier Analysis

data.analysisToolPak gains Fourier Analysis: the discrete Fourier transform
or its inverse of one row or column of 2^k values (at most 4096), by an
iterative radix-2 FFT. Input cells are read as IMREAL and IMAGINARY read
them, so complex text such as 3+4i works, and the results are written as
COMPLEX text. Tests check known transforms (1, 2, 3, 4; a sampled cosine),
the inverse round trip and the input limits.

* Data: Analysis ToolPak Random Number Generation and Sampling

data.analysisToolPak gains the last two of Excel's 19 tools. Random Number
Generation draws uniform, normal, Bernoulli, binomial, Poisson and discrete
numbers, or writes a patterned sequence, in one or more columns; binomial and
Poisson draws invert tables built from BINOM.DIST and POISSON.DIST. Sampling
takes every period-th value or a random sample with replacement. Both use a
seeded xoshiro256** generator: the same seed repeats the numbers, and a run
without one reports the seed it chose. Output is capped at 1,000,000 numbers.

* Data: Analysis ToolPak two-factor Anova without replication: Labels caption names the first column

With Labels ticked, Anova: Two-Factor Without Replication takes the column
names from the first row and the row names from the first cell of every data
row. The form's caption read "Labels in first row", so a user with column
headers but no row names who ticked it would lose the first data column. The
caption now reads "Labels in first row and first column", and the command's
params doc says the same. A dialog test checks the caption.

* Data: Analysis ToolPak docs: timestamp lines and revision history for the 19 tools

docs/gaps.md (§13) and docs/target-app-parity.md (what-if and analysis) gain
their Last updated line and a revision-history row for the Analysis ToolPak
reaching all 19 of Excel's tools.

* Data: Analysis ToolPak new tools refuse options that are not numbers

Random Number Generation (from, to, mean, standardDeviation, p, lambda, step,
and the whole-number options) and z-Test (hypothesizedDifference) read their
options with a default for anything that did not parse, so a form value typed
as "0,3" or a typo quietly ran with the default. A blank or absent option
still takes the default; any other value that is not a finite number is now
an error naming the option.

* Data: Analysis ToolPak refuses an Alpha that is not a number

alpha() took any value that did not parse as the default 0.05, so a typed
"0,01" ran z-Test, both two-factor Anovas (and the existing t-tests, F-Test
and single-factor Anova, which share it) at the wrong significance level
without a word. A blank or absent Alpha still means 0.05; any other value that
is not a finite number is now an error.

* Data: Analysis ToolPak Random Number Generation form hides the count for a patterned sequence

A patterned sequence takes its length from From, To, step and the two repeat
counts and ignores Number of Random Numbers, so a count typed there did
nothing. The form shows the count only for the random distributions, and the
patterned options carry a note that the repeats set how many values are
written.

* Data: Analysis ToolPak Random Seed caption says a blank seed draws a new sequence

Random Number Generation and random Sampling labelled the seed "blank for
none", but a blank seed makes the engine choose a new one, so each run draws
different numbers. The caption now reads "blank for a new sequence".

* Data: Analysis ToolPak form scrolls its options in a short window

The Data Analysis window cannot be resized, and the patterned Random Number
Generation form (seven distribution choices and five pattern fields, each
label above its box) is taller than a 600-point window, so OK ended below the
window's edge. The tool's options now sit in a vertical scroll area capped to
the window's height less the title, output options and buttons, which always
stay in view.

* Data: Analysis ToolPak docs: Forecast Sheet is no longer listed as missing

docs/target-app-parity.md's what-if and analysis row still listed Forecast
Sheet as missing, while data.forecastSheet is in the catalog and docs/gaps.md
§13 lists it as present. The row now lists it with the other analysis tools,
and today's revision-history row says so.

* Data: Analysis ToolPak seed applies only where numbers are drawn

A patterned sequence and periodic sampling draw nothing, but the form showed
the Random Seed field for both, and Random Number Generation read the seed for
every distribution: a seed left in the form that was not a whole number
refused a patterned run, and a valid one was reported as the seed used. The
seed field now shows only for the random distributions and random sampling,
and a patterned run neither reads nor reports a seed.

* Data: Analysis ToolPak Fourier Analysis keeps small parts beside large ones

The cleanup after the transform zeroed every part at or below 1e-13 of the
largest output, so a one-value input such as 1e14 + 1i lost its imaginary
part although a transform of one value performs no arithmetic, and a small
harmonic beside a large DC term was lost the same way. The cutoff is now the
rounding bound of the transform itself, 8 · ε · log2(N) · Σ(|re_n| + |im_n|),
divided by N for the inverse (whose outputs are divided by N): it depends on
the input and the number of butterfly stages, never on the largest output,
and is zero for N = 1. An input sum that overflows disables the cleanup
instead of zeroing everything. Two regression tests cover the one-value case
and an eight-point round trip of 1e6 + 1e-7 cos(2πn/8).

* Data: Analysis ToolPak binomial and Poisson tables grow with the distribution, not the trials

Random Number Generation tabulated a binomial or Poisson distribution by
evaluating BINOM.DIST or POISSON.DIST through worksheet-function dispatch
for every outcome from 0 upward, so a million trials with p = 1 meant
1,000,001 calls and a table of as many entries. The table is now built from
one value at the mode and the recurrence pmf(k + 1) = pmf(k) · (n − k)/(k + 1)
· p/(1 − p) (λ/(k + 1) for Poisson), outward in both directions: the left
tail ends where its terms vanish, the right tail once the cumulative
probability reaches 1 within f64 resolution, so the table is as wide as the
outcomes that matter (about 23,000 entries for Binomial(1,000,000, 0.5)).
p = 0 and p = 1 are exact cases with one outcome. The trials limit and the
draws are unchanged. Tests check the recurrence against BINOM.DIST and
POISSON.DIST to 1e-12 relative, the table width at a million trials, and
that the largest inputs complete in milliseconds with the right means.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-02 07:03:40 +01:00
AnhNgoo 94f8410ccf fix(ui): centralize platform-specific command shortcuts and test palette 2026-11-02 11:26:40 +07:00
Brandon ThomasandClaude Opus 5.5 092e5819a4 Docs: collapse duplicated target-app-parity rows from merging #3123 and #3130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:08:54 -05:00
Brandon Thomas 63607f881b Merge PR #3133
# Conflicts:
#	docs/ui-parity.md
2026-11-01 23:08:38 -05:00
Brandon Thomas e361f922d5 Merge PR #3130
# Conflicts:
#	docs/file-format-parity.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-11-01 23:08:37 -05:00
Brandon Thomas c3e3e55f83 Merge PR #3123
# Conflicts:
#	crates/engine/src/cmd/insert.rs
2026-11-01 23:08:37 -05:00
Brandon ThomasandClaude Opus 5.5 ea2dfe66c2 Review fix for #3135: clip only whole-column and whole-row chart sources; bounded areas keep their extent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:08:15 -05:00
Brandon Thomas 50f4b4fe29 Merge PR #3135 2026-11-01 23:06:26 -05:00
NetterSilverandClaude Opus 5.5 a847f839e3 Clip whole-column chart sources to the used range
Fixes #3114

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 03:43:31 +01:00
prxnvt 3bec72f643 Docs: Python mode in the formula bar (UI parity, functions, architecture, control protocol, roadmap)
ui-parity.md gains a Python mode row and a shortcut row for
Ctrl+Alt+Shift+P with its Mac form. function-parity.md's Python in
cells row names Python mode. architecture.md describes Python mode on
the shared editor, code_field and its stop keys, and drafts that follow
their sheet by its identity. control-protocol.md lists ui.pythonMode,
the editor's python flag in ui.inspect, and when ui.inspect leaves a
draft out.
ROADMAP.md records the progress.
2026-11-01 21:02:03 -05:00
prxnvt 6ed56683bf i18n: Python Mode in every catalog
The name of the PY badge beside fx, which turns Python mode on and off,
in all 18 interface catalogs. cargo xtask i18n-report doesn't list it as
missing.
2026-11-01 21:02:03 -05:00
prxnvt 9ac06dd8cc UI: Python mode in the formula bar
Python cells get an editing mode of their own in the formula bar, in the
code editor (monospace, Python colours). It starts with Ctrl+Alt+Shift+P
(Cmd+Option+Shift+P on macOS), with =PY then Tab as in Excel, or by
editing a Python cell (F2, a double-click, a click on the formula bar);
typing over a Python cell still replaces it. The shortcut again leaves
Python mode and keeps the text as an ordinary entry.

In Python mode Enter starts a new line with the indentation, Tab and
Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits the code
through python.set and Esc cancels. Every commit path of a Python edit
goes through python.set, the formula bar's Enter button and a click on
the sheet included. When the command refuses the code (longer than a
cell holds), its message shows and the edit stays open with the code as
typed. A locked cell on a protected sheet is refused before a draft
exists, and a draft whose cell protection locks while it is open is
dropped with the warning, as an ordinary edit is (#2945). A Python cell's code committed unchanged writes
nothing (no undo step), as F2 then Enter on any other cell does. With
all the code deleted, the commit clears a Python cell through cell.set,
as an empty entry clears any other cell (code of spaces is still code);
on another cell it writes nothing. The formula bar shows while a Python
edit is open, also when View > Formula Bar hides it, since Python mode
edits only there. A Python draft records the window it was typed in,
as an ordinary draft does, so it is committed in that window when
another comes forward.

Python mode replaces the route that sent a changed edit of a Python
cell's code through python.set (EditState.code_edit and
commit_code_edit): every such edit now starts in Python mode, so that
route is removed, and leaving Python mode commits the text as an
ordinary entry through cell.set. The UI tests of that route commit with
Ctrl+Enter, since Enter starts a new line in Python mode; the guard that
keeps a column completion out of a code edit now reads Python mode.

The keys are applied in input order with the typing, on main's
apply_in_order: the code editor takes a set of stop keys from its caller
(apply_in_order_until), so Ctrl+Enter, Esc and the shortcut act on the
text as it was when they were pressed. After a key the text field acts
on (select all), a stop key waits for the next pass with the editor's
keys before it, so select all, Tab and Ctrl+Enter in one frame commits
the indented code. Keyboard events after a stop key in the same pass are
dropped. code_field is the editor without its frame, for the bar.

The PY badge beside fx shows in Python mode and on a selected Python
cell; clicking it switches Python mode as the shortcut does.
ui.pythonMode {on?} does the same from the control channel, without a
dialog, and engine.commands lists it. ui.inspect reports editor.python,
and leaves a Python draft's code out (text null, hidden true) while its
cell's code is hidden on a protected sheet, checked when it is called:
a protection command earlier in the same batch runs before the next
frame. Theme tokens badge_py,
on_badge, sheet_py_tag and on_sheet_py_tag join the contrast test.

A draft, Python or not, records its sheet's identity (Sheet::uid) when
it starts and follows that sheet, since commands that run while it is
open (sheet.move, home.insertSheet, home.deleteSheet, their undo, a
macro, a tool that adds a report sheet) change which sheet sits at its
index. A commit goes to the draft's own sheet; before, a draft committed
after a sheet was inserted before its own went into the inserted sheet,
an ordinary draft as well. A draft whose sheet is gone is dropped with a
message, as one whose cell protection locks is. ui.inspect checks for
hidden code on the draft's own sheet, and once that sheet is gone it
leaves out a Python draft, or one that began on a Python cell.
2026-11-01 21:02:03 -05:00
prxnvt 8b96d30c2b Engine: python.output switches a Python cell between Python Object and Excel Value
A Python cell's return type had no command of its own: python.set takes
it, but only together with the code. python.output {cell?, mode?} sets
it on its own; its command entry lists Excel's shortcut,
Ctrl+Alt+Shift+M. Without a mode it switches to the other one, so the
shortcut needs no parameters; a cell that isn't a Python cell is an
error. A cached result belongs to the return type it was made for, so
a switch treats it as python.set treats a new return type: the result
goes, the cell shows #BLOCKED! until it runs, and its old spill goes
with what read it recalculated. Both commands write the cell through
one path, so python.output refuses a table header and a locked cell on
a protected sheet as python.set does. Each switch is one undo step, and
setting the mode a cell already has adds none.

python.set now refuses an output that isn't text instead of ignoring it.
The command-sequence fuzzer runs python.output too. A macro recording
keeps the return type a switch without a mode set, as its mode (as Bold
records on), also for a switch inside a recorded script run, so playing
it back sets that type rather than switching again.

Tests: switching without a mode drops the cached result, its spill and
the value its reader saw, the same as python.set with the other return
type; switching back brings no result back; the code is kept; one undo
step per switch, undo brings the result back, and none for the mode a
cell already has; a cell without a result stays #BLOCKED!; the
refusals (no Python cell, a bad or non-text mode, a bad cell, a table
header, a locked cell, a non-text output for python.set), an unlocked
Hidden cell on a protected sheet switches, and a recorded switch played
back on a cell that already has the new type keeps it.
2026-11-01 20:13:08 -05:00
prxnvt c1cc8264fe Model: sheets carry an identity while the app runs
Each sheet gets an identity (gridcraft_model::SheetUid, Sheet::uid) for
what must follow one sheet while sheets are inserted, deleted, moved or
renamed, which a sheet index or a name can't do. It lives only while the
app runs: it isn't saved to JSON or a file, and a new, inserted, copied
or opened sheet gets a fresh one. A rename, a move and the copies undo
keeps don't change it, so undo of a deletion brings back the same sheet.
Sheets compare by content, whatever their identity: any two identities
compare equal, and SheetUid::get compares them.

sheet.move with copy gives the copy a fresh identity. Tests cover new
sheets, clones, JSON, renames, moves, deletion and undo, copies and a
workbook read from a file.
2026-11-01 20:13:05 -05:00
Ingmar LübbeandClaude Opus 5.5 f46c47f3cf Formulas: Trace Error draws arrows back to the cell an error starts in
New query command formulas.traceError and a Trace Error item in the
Error Checking menu, between Error Checking… and Circular References.
From the cell it follows the first reference that shows the same error
(in a range, its first cell showing it) to the cell the error starts in,
as measured in Excel, and returns that source and the arrows: links
between the error cells, and arrows from the source's own references.
The walk visits each cell once and stops after 10,000 links.

The grid's trace now holds arrows with the sheets of both ends, built
once from the command's result. Error links are drawn in a new red sheet
token, the other arrows in a token for the blue they had. "Trace Error"
is translated in all 18 catalogs (German: Spur zum Fehler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAgnkqJiy3v24Hp25dQLRj
2026-11-02 01:59:50 +01:00
Ingmar LübbeandClaude Opus 5.5 e2c523881f Page Layout: Page Setup's page order (Down, then over / Over, then down)
A sheet more than one page wide and tall always printed down, then
over. PrintSettings gains the page order (serde default Down, then
over), pageLayout.pageSetup takes pageOrder, and the pagination numbers
the pages in it, so printing, PDF export, the print preview, Page Break
Preview, Page Layout view and the &P/&N, first-page and odd/even
headers follow the printed sequence. XLSX reads <pageSetup pageOrder>
and writes pageOrder="overThenDown" only, as Excel does.

The Page Setup dialog gets a Page order choice with a small picture of
the order and runs pageLayout.pageSetup, so the order and the margins
are one undo step; the labels are translated where the dialog is, and
the dialog is now in German too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAgnkqJiy3v24Hp25dQLRj
2026-11-02 01:43:14 +01:00
Ingmar LübbeandClaude Opus 5.5 bf79931b30 Formulas: trace arrows stay when the sheet is zoomed (#3097)
A zoom changes the workbook's revision, which the trace arrows are tied
to, so every zoom removed the arrows of Trace Precedents and Trace
Dependents. Zooming moves no cell: Trace::keep_through_zoom carries
arrows that were current over a zoom from the View tab, the Zoom dialog,
the status bar's buttons and slider, Ctrl+wheel and the control channel.
Undoing a zoom is still an edit and removes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAgnkqJiy3v24Hp25dQLRj
2026-11-02 01:08:19 +01:00
prxnvt 52b00fe8f8 Engine: an edit that would make Python code too long is refused
Structural edits, sheet renames and deletes, and a cut to another sheet
rewrite the xl() references in Python code, and could make a cell's code
longer than the 8,192-character limit that python.set enforces. Opening the
saved file then dropped that code to its value. Such an edit is now refused
with a message naming the cell, and nothing changes; keeping the old text
would have left a reference the edit had moved.
2026-11-01 15:35:54 -05:00
campbellcodyandCodex b6c4500fa7 M7: add data tables beneath charts
Add command and UI controls for chart data tables, shared chart and PDF rendering, and native XLSX persistence. Cover live values, blanks, formatting, alignment, undo/redo, bounds and table flags with regression tests.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-01 15:30:46 -05:00
prxnvt 0151f43af8 Engine: python.set keeps a cell's result only for the same code and return type
python.set kept the stored result whenever the code was unchanged, also when
the call switched the return type between an Excel value and a Python object.
The cell then showed a value and preview of the other type, and a save wrote
the new type with that old cached value. The result is now kept only when both
the code and the return type match; otherwise the cell is new code, which
shows #BLOCKED! until it runs, and its old spill goes with its readers
recalculated.
2026-11-01 15:11:59 -05:00
prxnvt 11b55e0c71 Calc: new Python code recalculates the readers of the cell's old spill
python.set, and Replace in a Python cell's code, removed the cell's spill
registration and its spilled values before the recalculation ran, so the
recalculation never saw the freed range and formulas reading those cells kept
their old values until a full recalculation. The commands now leave the spill
to the recalculation. When a Python cell's content changes and it has no array
result to lay out, cells_changed removes the spill and queues the freed range,
as it does for a formula replaced by a constant, so its readers are
recalculated; a cell that keeps its array result (the same code again) keeps
its spill and lays it out again. Clearing, pasting over and blocking a Python
spill already recalculated its readers, and undo and redo restore the values
along with the cells; a test covers them.
2026-11-01 14:41:48 -05:00
prxnvt 8e816dff1e Engine: Save Model asks before it writes over a Python cell
Save Model (new on main) asks before it replaces data in the column it writes
to, but it looked for a value or a formula only, so a Python cell whose cached
value is empty was taken for an empty cell and its code was replaced without
the question. Python code now counts as content there, as it does elsewhere.
2026-11-01 14:40:50 -05:00
prxnvt 745e937b0b Merge main into Python cells as data 2026-11-01 14:38:52 -05:00
PranavandFredApps f5030432fc Data: Solver Options (#3087)
* Data: Solver Load/Save Model and Reset All

data.solverSaveModel writes a model (the sheet's saved model, or one
given as data.solver params) down a column of its sheet as formulas:
the objective and goal (=MAX(...), =MIN(...) or =cell=value), the
variable cells (=COUNT(...)), one =AND(relation) per constraint, which
shows TRUE while the constraint holds, and the method and its options
as an array constant. It refuses a range that overlaps the model's
cells or runs off the sheet.

data.solverLoadModel reads such a range (or reads down from its first
cell), checks the model and makes it the active sheet's model.
data.solverReset (Reset All) removes the active sheet's model. Each is
one undo step.

The Solver Parameters dialog has Reset All and Load/Save buttons; the
dialog can also be opened with fields that go over the saved model.

* Data: Solver Reset All also clears a model read from workbook-level names

A sheet's Solver model can come from workbook-level names, which every
sheet without a model of its own reads. Reset All removed only the
sheet-level names, so such a model stayed and the dialog showed it
again.

Removing the workbook-level names would change every other sheet that
reads them. Reset All instead leaves them in place and writes an empty
sheet-level solver_opt over them on the active sheet only; the result
says masked: true. saved_model now reads a sheet's own names when the
sheet has a solver_opt, and the workbook-level names only otherwise,
never a mix of both, and treats an empty solver_opt as no model. The
mask survives XLSX and is replaced when a model is saved on the sheet.

* Data: Solver Save Model clears the rest of a longer model saved at the same cell

Saving a model over a longer one saved at the same cell left the old
model's last rows on the sheet, so Load Model from that cell read the new
options row followed by stale constraint rows and refused the range.
Save Model now clears what is left of a model saved there before (only
when those cells hold a complete model written by Save Model, and never
the new model's own cells), keeping the cells' formatting, in the same
undo step. Load Model reading down from one cell also stops at the
options row, so formulas just below a saved model no longer break it.

* Data: Solver Load Model keeps the Evolutionary convergence and generations

Save Model writes Convergence and the generations without improvement in
its options row and Load Model reads them, but the sheet's saved model
(hidden names) did not hold them, so after Load Model, or any solve,
they fell back to their defaults. The saved model now keeps them for the
Evolutionary method: Convergence as solver_cvg, which means the same in
Excel, and the generations as gridcraft_solver_stall, because Excel's
solver_mni is a time in seconds. Saving a model again and Reset All
remove the GridCraft name with the solver_* names.

* Data: Solver Load Model refuses a range with a gap

Load Model from a range of several cells kept only the cells that hold a
formula, so the rows below a cell without one moved up. A constraint cell
overwritten by a constant gave a model without that constraint, and an
empty cell before the options row gave the default options, both without
an error. Load Model now refuses a range in which a cell before the last
formula holds no formula, and names that cell. Empty cells after the
model are still allowed.

* Data: Solver removes only its own names when it saves or resets a model

Saving a model (by a solve or Load Model) and Reset All removed every
name of the sheet that starts with solver_, so a user's own name such as
solver_budget was deleted with the model. They now remove only the
Solver's names: the ones Excel's Solver writes (solver_opt, solver_adj,
solver_lhs1, solver_rel1, solver_rhs1 and so on) and GridCraft's own.
The names are matched by name rather than by the hidden flag, so a model
whose names lost that flag is still replaced in full.

* Data: Solver Save Model refuses a place where the options cannot spill

The options row that Save Model writes is an array constant of eight
values, which spills into the seven cells to the right of its first
cell. Content in any of those cells blocked the spill and the options
cell showed #SPILL!. Save Model now checks the whole spill range before
it writes anything and refuses, naming the cell, when a cell there holds
content or another formula's spilled values or is a cell of the model,
when merged cells or a table meet the row (a dynamic array cannot spill
into a table), or when the range runs past the last column. It also
refuses a place where the model's column would cover another formula's
spilled values, which that formula would lose to #SPILL!. The options of
a model saved at the same cell before may be replaced, as before.

* Data: Solver Reset All and Load Model drop the old model's options

Reset All cleared the model fields of the Solver Parameters form but
kept the Evolutionary options (Convergence, Mutation Rate, Population
Size, Random Seed, generations without improvement and Require Bounds),
and Load Model kept any of them that the loaded model omits, such as all
of them for a GRG Nonlinear or Simplex LP model. A new Evolutionary model
then solved with the old options, for example with Require Bounds off.
Both now remove the model's options from the form first (one list of
their keys), so an option the model doesn't set takes its default.

* Data: Solver Save Model asks before it replaces data

Save Model wrote its formulas over whatever the model range held.
It is now refused, with the sheet unchanged, when a cell of the
range holds a value or a formula (a model saved there before
included), unless `overwrite: true` is passed. The message names
the first such cell and carries the same "already holds data"
phrase as Data Analysis, so the Solver Parameters dialog asks
"Replace it with the model?" with Replace and Don't Replace. The
question belongs to the range it asked about: editing the range,
Load or Reset All drops it. The check runs after the spill checks,
so the question is asked only when replacing would succeed. A save
remains one undo step.

Tests: the engine test refuses a value, a formula and an earlier
model, accepts only a true boolean, and undoes and redoes the
replacing save in one step; the dialog test covers Don't Replace,
an edited range and Replace. Both fail without the change.

* Data: Solver Load Model and Reset All tests for limits and undo

Tests for behaviour already in place: reading down from one cell of
a column of 5,000 formulas stops after the most rows a model takes,
a range larger than a model is refused before any cell is read, and
malformed or hostile cells and ranges (unbalanced brackets, an
overflowing number, an unknown sheet, 10,000 nested brackets, a
non-text `from`) each give an error and leave no model. Undoing
Reset All brings the model back with its options (the population
size).

* Data: Solver Load Model refuses a model without its options row

Load Model from one cell read down to the first cell without a
formula, and a range dropped its trailing cells without formulas.
A constraint cleared in the middle of a saved model, or its options
row cleared or replaced by a constant, therefore left a prefix of
the model, which was loaded as the whole model with default options
and replaced the sheet's model. Every load now requires the model's
last row to be the options row ({...}) that Save Model writes last;
otherwise it is refused, naming the cell without a formula when
reading down from one cell, and the sheet's model stays as it was.

The test clears a middle constraint, clears the options row and
replaces it by a constant, loads from the first cell and as two
ranges, and checks after each refusal that the old model (three
constraints, Simplex LP) is unchanged. It fails without the change.

* Data: Solver Options

The Solver Parameters dialog's Options button shows the Solver Options
in the same window: Max Evaluations and Integer Optimality for all
methods, and Convergence, Mutation Rate, Population Size, Random Seed,
Maximum Generations without Improvement and Require Bounds on Variables
for Evolutionary. OK keeps the changes for the next solve; Cancel
restores them.

The evaluation limit and Integer Optimality are now part of the model:
they are saved in the hidden names (solver_itr, which counts evaluations
in place of iterations, and solver_tol) and in the options row of Save
Model. Both accept numbers or text, as the other options do.

* Data: Solver keeps its own limits out of Excel's Solver names

The evaluation limit was saved as solver_itr. Excel uses that name for
its iteration limit (100 by default), so a workbook saved by Excel would
give GridCraft 100 evaluations and stop its search early. GridCraft now
saves the evaluation limit as gridcraft_solver_evals, which Excel
ignores, and no longer reads solver_itr. Saving a model again and Reset
All remove it with the other model names. Integer Optimality
(solver_tol) keeps Excel's name, as its meaning is the same.

* Data: Solver Reset All also resets the Solver Options

Reset All removes the model's options from the Solver Parameters form,
the Solver Options fields (the evaluation limit, Integer Optimality and
the Evolutionary options) and Require Bounds, so the next Solve uses the
defaults. It now also removes the copy of the options that Options keeps
for Cancel. A test checks that the options are gone after Reset All and
that Options then shows the defaults.

* Data: Solver reads Integer Optimality with a percent sign as percent

Integer Optimality is given in percent, but text such as "5%" was read
by the number parser as 0.05 and then divided by 100 again, so the
solver used 0.05 % instead of 5 %. A trailing percent sign is now
dropped before the number is read, so "5", "5%" and 5 all mean 5 %.

* Data: Solver Options closes with Escape back to the Solver Parameters

Escape in the Solver Options view went to the shared dialog handler,
which closed the whole Solver dialog: the options were not restored and
parameter edits made before opening Options were lost. Escape (and
closing the window) in Solver Options now does what Cancel does: it
restores the options and returns to the Solver Parameters. Escape in the
parameters still closes the dialog.

* Data: Solver keeps the Evolutionary options when another method solves

Solver Options sets the Evolutionary options whatever the solving
method, but the saved model and the model in data.solver's result held
them only for the Evolutionary method. A GRG Nonlinear or Simplex LP
solve (which saves the model by default) dropped them, and switching
back to Evolutionary brought back the defaults. They are now saved and
returned for every method. Two tests that expected them to be absent
for other methods now check their values.

* Data: Solver Options keeps its OK and Cancel row at button height

The Solver Options' OK and Cancel row was a right-to-left layout placed
straight in the window, which takes all the height it is offered, so
the buttons sat well below the last option and the window was taller
than its content. The row is now wrapped in a horizontal row as tall as
the buttons, as the other form dialogs do (ok_cancel).

The window's height now changes when it switches between the parameters
and the options, and the window re-centres over the next frames, so the
test that switches back and forth lets a few frames pass after each
switch before it clicks.

* Data: Solver Parameters keeps its Solve and Close row at button height

The Solve and Close row was a right-to-left layout placed straight in
the window. The window keeps its size when its content gets shorter, as
it does when the Load/Save area or a note is hidden again, and that
layout then filled the space left over, so Solve moved further below
the last row. The row is now a horizontal row as tall as the buttons,
as ok_cancel and the Solver Options do.

* Data: Solver dialog opened in its options sets them up as the Options button does

Opening the Solver dialog straight in the Solver Options (optionsView:
true) only changed the title. It skipped what the Options button does:
filling each option the form lacks with its default and keeping the
current values for Cancel. With no saved model the fields were blank,
and Cancel or Escape found nothing to restore, so the edits stayed and
the next Solve used them. The button and the dialog now share one
function for this.

* Data: Solver reads Integer Optimality back without floating-point noise

Integer Optimality is stored as a fraction, and the product with 100 is not exact for many percents (0.07 * 100 is 7.000000000000001). The percent is now rounded to 1E-10 % wherever it is read back: the saved model, the solve's model params and Save Model's options row.

* Data: Solver reads whole-number options back as integers

The model's numeric options were returned as floating-point JSON, so after a solve the Solver Options showed 20000.0, 100.0, 0.0 and 50.0. Values read from the hidden names, and Integer Optimality in the solve's result, are now JSON integers where they are whole, and the dialog shows 20000, 100, 0 and 50.

* Data: Solver test checks the model saved by the second solve

The test of Excel's iteration and time limits ignored the result of
its second `data.solver` call and checked only names the first call
had already created. It now reads the saved model afterwards and
checks the values the second call changed (Simplex LP and an
evaluation limit of 700). The call itself still fails, as Simplex LP
refuses the nonlinear model, after the model is saved.

* Data: Solver Load Model refuses an options row cut short

The options row of a saved model was read leniently: a missing value
or one that isn't a number took its default, and values after the
sixteenth were not read at all. A row cut short or edited by hand,
such as ={1}, therefore loaded the model with default options in
place of the saved ones (here: GRG Nonlinear in place of Simplex
LP). The row must now hold at least the eight numbers Save Model
writes (method, non-negative, population size, mutation rate, random
seed, require bounds, convergence, generations without improvement),
and every value in it, however many, must be a number; otherwise
Load Model is refused and the sheet's model stays as it was. Only
the first sixteen values are kept. More numbers than eight are still
read, so a row with options a later version adds loads too.

The test loads rows of one value, seven values, a text value among
the eight, after them and after the sixteenth, and a TRUE, from the
first cell and as a range, checking the old model after each
refusal, and a row of ten numbers, which loads. It fails without the
change.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-01 20:23:08 +01:00
PranavandFredApps 71b130be2d Data: Solver Load/Save Model and Reset All (#3086)
* Data: Solver Load/Save Model and Reset All

data.solverSaveModel writes a model (the sheet's saved model, or one
given as data.solver params) down a column of its sheet as formulas:
the objective and goal (=MAX(...), =MIN(...) or =cell=value), the
variable cells (=COUNT(...)), one =AND(relation) per constraint, which
shows TRUE while the constraint holds, and the method and its options
as an array constant. It refuses a range that overlaps the model's
cells or runs off the sheet.

data.solverLoadModel reads such a range (or reads down from its first
cell), checks the model and makes it the active sheet's model.
data.solverReset (Reset All) removes the active sheet's model. Each is
one undo step.

The Solver Parameters dialog has Reset All and Load/Save buttons; the
dialog can also be opened with fields that go over the saved model.

* Data: Solver Reset All also clears a model read from workbook-level names

A sheet's Solver model can come from workbook-level names, which every
sheet without a model of its own reads. Reset All removed only the
sheet-level names, so such a model stayed and the dialog showed it
again.

Removing the workbook-level names would change every other sheet that
reads them. Reset All instead leaves them in place and writes an empty
sheet-level solver_opt over them on the active sheet only; the result
says masked: true. saved_model now reads a sheet's own names when the
sheet has a solver_opt, and the workbook-level names only otherwise,
never a mix of both, and treats an empty solver_opt as no model. The
mask survives XLSX and is replaced when a model is saved on the sheet.

* Data: Solver Save Model clears the rest of a longer model saved at the same cell

Saving a model over a longer one saved at the same cell left the old
model's last rows on the sheet, so Load Model from that cell read the new
options row followed by stale constraint rows and refused the range.
Save Model now clears what is left of a model saved there before (only
when those cells hold a complete model written by Save Model, and never
the new model's own cells), keeping the cells' formatting, in the same
undo step. Load Model reading down from one cell also stops at the
options row, so formulas just below a saved model no longer break it.

* Data: Solver Load Model keeps the Evolutionary convergence and generations

Save Model writes Convergence and the generations without improvement in
its options row and Load Model reads them, but the sheet's saved model
(hidden names) did not hold them, so after Load Model, or any solve,
they fell back to their defaults. The saved model now keeps them for the
Evolutionary method: Convergence as solver_cvg, which means the same in
Excel, and the generations as gridcraft_solver_stall, because Excel's
solver_mni is a time in seconds. Saving a model again and Reset All
remove the GridCraft name with the solver_* names.

* Data: Solver Load Model refuses a range with a gap

Load Model from a range of several cells kept only the cells that hold a
formula, so the rows below a cell without one moved up. A constraint cell
overwritten by a constant gave a model without that constraint, and an
empty cell before the options row gave the default options, both without
an error. Load Model now refuses a range in which a cell before the last
formula holds no formula, and names that cell. Empty cells after the
model are still allowed.

* Data: Solver removes only its own names when it saves or resets a model

Saving a model (by a solve or Load Model) and Reset All removed every
name of the sheet that starts with solver_, so a user's own name such as
solver_budget was deleted with the model. They now remove only the
Solver's names: the ones Excel's Solver writes (solver_opt, solver_adj,
solver_lhs1, solver_rel1, solver_rhs1 and so on) and GridCraft's own.
The names are matched by name rather than by the hidden flag, so a model
whose names lost that flag is still replaced in full.

* Data: Solver Save Model refuses a place where the options cannot spill

The options row that Save Model writes is an array constant of eight
values, which spills into the seven cells to the right of its first
cell. Content in any of those cells blocked the spill and the options
cell showed #SPILL!. Save Model now checks the whole spill range before
it writes anything and refuses, naming the cell, when a cell there holds
content or another formula's spilled values or is a cell of the model,
when merged cells or a table meet the row (a dynamic array cannot spill
into a table), or when the range runs past the last column. It also
refuses a place where the model's column would cover another formula's
spilled values, which that formula would lose to #SPILL!. The options of
a model saved at the same cell before may be replaced, as before.

* Data: Solver Reset All and Load Model drop the old model's options

Reset All cleared the model fields of the Solver Parameters form but
kept the Evolutionary options (Convergence, Mutation Rate, Population
Size, Random Seed, generations without improvement and Require Bounds),
and Load Model kept any of them that the loaded model omits, such as all
of them for a GRG Nonlinear or Simplex LP model. A new Evolutionary model
then solved with the old options, for example with Require Bounds off.
Both now remove the model's options from the form first (one list of
their keys), so an option the model doesn't set takes its default.

* Data: Solver Save Model asks before it replaces data

Save Model wrote its formulas over whatever the model range held.
It is now refused, with the sheet unchanged, when a cell of the
range holds a value or a formula (a model saved there before
included), unless `overwrite: true` is passed. The message names
the first such cell and carries the same "already holds data"
phrase as Data Analysis, so the Solver Parameters dialog asks
"Replace it with the model?" with Replace and Don't Replace. The
question belongs to the range it asked about: editing the range,
Load or Reset All drops it. The check runs after the spill checks,
so the question is asked only when replacing would succeed. A save
remains one undo step.

Tests: the engine test refuses a value, a formula and an earlier
model, accepts only a true boolean, and undoes and redoes the
replacing save in one step; the dialog test covers Don't Replace,
an edited range and Replace. Both fail without the change.

* Data: Solver Load Model and Reset All tests for limits and undo

Tests for behaviour already in place: reading down from one cell of
a column of 5,000 formulas stops after the most rows a model takes,
a range larger than a model is refused before any cell is read, and
malformed or hostile cells and ranges (unbalanced brackets, an
overflowing number, an unknown sheet, 10,000 nested brackets, a
non-text `from`) each give an error and leave no model. Undoing
Reset All brings the model back with its options (the population
size).

* Data: Solver Load Model refuses a model without its options row

Load Model from one cell read down to the first cell without a
formula, and a range dropped its trailing cells without formulas.
A constraint cleared in the middle of a saved model, or its options
row cleared or replaced by a constant, therefore left a prefix of
the model, which was loaded as the whole model with default options
and replaced the sheet's model. Every load now requires the model's
last row to be the options row ({...}) that Save Model writes last;
otherwise it is refused, naming the cell without a formula when
reading down from one cell, and the sheet's model stays as it was.

The test clears a middle constraint, clears the options row and
replaces it by a constant, loads from the first cell and as two
ranges, and checks after each refusal that the old model (three
constraints, Simplex LP) is unchanged. It fails without the change.

* Data: Solver Load Model refuses an options row cut short

The options row of a saved model was read leniently: a missing value
or one that isn't a number took its default, and values after the
sixteenth were not read at all. A row cut short or edited by hand,
such as ={1}, therefore loaded the model with default options in
place of the saved ones (here: GRG Nonlinear in place of Simplex
LP). The row must now hold at least the eight numbers Save Model
writes (method, non-negative, population size, mutation rate, random
seed, require bounds, convergence, generations without improvement),
and every value in it, however many, must be a number; otherwise
Load Model is refused and the sheet's model stays as it was. Only
the first sixteen values are kept. More numbers than eight are still
read, so a row with options a later version adds loads too.

The test loads rows of one value, seven values, a text value among
the eight, after them and after the sixteenth, and a TRUE, from the
first cell and as a range, checking the old model after each
refusal, and a row of ten numbers, which loads. It fails without the
change.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-01 20:13:00 +01:00
prxnvt 0524c04756 Engine: Merge Across keeps only the upper-left Python code, and unmerging unblocks a Python spill
Merge Across emptied the value and formula of the other cells in each row but
left their Python code, so a hidden cell stayed a Python cell with an empty
value and was saved as a PY formula. It now clears their whole content, as
Merge Cells and Merge & Center already did. In the same command, a merge
change evaluates again the blocked arrays that could reach the area, but only
formulas were picked, so a Python result blocked by a merged cell stayed
#SPILL! after the cells were unmerged; Python cells are picked too.
2026-11-01 14:08:08 -05:00
prxnvt 6c478475e4 UI: a Python code edit is never completed from the column
While a Python cell's code was edited (F2 or the formula bar), typing at the
end of the code could offer AutoComplete from a text value in the same column,
and Enter then committed that text as the code. The editor no longer offers a
completion during a code edit, and committing a code edit ignores one.
2026-11-01 14:07:15 -05:00
prxnvt cd51fe110d Calc: a range read walks only the spills it overlaps
A range read during recalculation walked every array laid out in the pass on
the sheet, whatever the range, and a block read (visit_range) gave up its fast
path as soon as any array was laid out there. A Python cell's cached result
can be up to 1,000,000 cells, so 1,000 formulas reading small ranges beside it
could visit about a billion values in one full recalculation; a large formula
array costs the same. Both reads now look only at the arrays whose area meets
the range, and only at the part inside it. The spilled values the sheet stores
from the last calculation are found row by row when the range has fewer rows
than there are spilled values, so a read beside them doesn't scan them either.
2026-11-01 14:06:02 -05:00
prxnvt b3a3590db6 Merge main into Python cells as data 2026-11-01 14:03:09 -05:00
Brandon ThomasandClaude Opus 5.5 30b210b4ef Release 0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:31 -05:00
aiongg 29b17a6ff8 Merge pull request #467 from prxnvt/pr/function-library
UI: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories
2026-11-01 13:44:57 -05:00
aiongg 8cd1be510c Merge pull request #2597 from prxnvt/pr/fuzz-view-mode-undo
Undo and redo keep each sheet's view (Normal, Page Layout, Page Break Preview)
2026-11-01 13:44:16 -05:00
prxnvt ec7ee8d4ea Engine: a Python cell that keeps only its value keeps its whole cached result
When a file is opened, a Python cell falls back to keeping only its value in
four cases: its code is missing from the Python part or the part is damaged
(the xlsx reader), its code is too long for a cell, its kept result is
malformed, or its result is past the adoption budget. The first kept the
values the cell spilled as plain values; the others dropped them, so values
the file showed were lost. All of them now follow one rule, through
Sheet::settle_spill: the anchor and every spilled cell keep their cached
values as plain values, with no live spill range; a cell's own content and
format are never overwritten; and the work is bounded by the spilled values
stored, not by the range. The values move from the spill into the cells, so
nothing is copied. The cut-by-budget test is renamed for what it now checks,
the dropped-result test now expects the values kept, and the adoption warning
and the file-format docs say so.
2026-11-01 13:28:19 -05:00
FredApps e1e72426e5 Fix merged keytip sequence count for HBJ and MR 2026-11-01 19:09:04 +01:00
FredApps dc095444f2 Merge current main into PR2597 for hosted validation 2026-11-01 19:06:22 +01:00
FredApps 10b763e7a4 Merge current main into PR467 and preserve both documentation histories 2026-11-01 19:06:22 +01:00
prxnvt 84d4f6aa84 XLSX: a Python script's length is measured after its escapes are decoded
A script's code was cut at the character limit before its ST_Xstring escapes
were decoded, so a script whose escapes (`_x0009_`, `_x000D_`) made the file's
text longer than the limit lost its end without a warning, although its code
fits. The code is now decoded first and then cut one character past the limit,
so a script that is too long once decoded still fails the check and its cell
keeps only its value, as before. Decoding is linear and reads at most a bounded
number of encoded characters, enough for any code that fits.
2026-11-01 12:58:13 -05:00
prxnvt e82e91b451 XLSX: a Python cell without its code keeps its whole cached result as values
A PY formula whose script the file doesn't hold (the Python part missing or
damaged, or the script over the code budget) keeps only its cached value, but
its array range stayed recorded, so the cached values over the rest of its
spill were dropped as a formula's spill would be, and nothing filled them
again. The range of such a cell is now forgotten when it is read: the anchor
and the cells of its spill keep their cached values as plain values, as the
file shows them, with no live spill. An ordinary formula's dynamic-array spill
is handled as before.
2026-11-01 12:56:55 -05:00
prxnvt 113aeff07c XLSX: a damaged Python part is a warning, and the workbook opens with its values
A Python part whose zip entry failed to read (a bad checksum or compressed
stream) made the whole workbook fail to open: the error was passed up, and the
failed read had also ended the package's read budget, so even a warning would
have left the sheets unread. The part is now read with
Package::read_optional_limited, which charges a failed read only that part's
allowance, and every error other than TooLarge is a warning. The Python cells
then keep only their cached values, as when the part is missing. Malformed XML
in the part was already a warning; the test covers it too.
2026-11-01 12:36:04 -05:00
prxnvt 44e9ddad45 Engine: a Python result cut by the adoption budget takes its cached spill with it
When a file's cached Python spills were larger than the adoption budget, the
cells past it kept only their own value, but their spill ranges and spilled
values stayed on the sheet, which went on showing values no result held. The
spill of such a cell is now cleared, as for a dropped result; the warning is
unchanged.
2026-11-01 12:33:22 -05:00
Dylan M. Taylor 6917788124 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:04 -05:00
Brandon ThomasandClaude Opus 5.5 ea1d52fe8f Review fixes for #3042: "" with parse: false keeps the cell's formatting; correct the write_range parse description
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:20:54 -05:00
Brandon Thomas 653227fe6e Merge PR #3077 2026-11-01 12:20:06 -05:00
Brandon Thomas e129c072a1 Merge PR #3075
# Conflicts:
#	crates/xlsx/src/read.rs
#	docs/file-format-parity.md
2026-11-01 12:20:06 -05:00
Brandon Thomas bc173ead8f Merge PR #3062
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
2026-11-01 12:19:55 -05:00
Brandon Thomas 7f1574b573 Merge PR #3052
# Conflicts:
#	docs/file-format-parity.md
2026-11-01 12:19:55 -05:00
Brandon Thomas 6a3aa58a07 Merge PR #3043
# Conflicts:
#	ROADMAP.md
2026-11-01 12:19:55 -05:00
Brandon Thomas e6a8d4a8b6 Merge PR #3042 2026-11-01 12:19:44 -05:00
Brandon Thomas 49ed44cb02 Merge PR #3040 2026-11-01 12:19:44 -05:00
Brandon Thomas 229b6001ec Merge PR #3083 2026-11-01 12:18:01 -05:00
Brandon Thomas 1975360120 Merge PR #3050 2026-11-01 12:18:01 -05:00
Brandon Thomas 81ee96fa45 Merge PR #3041 2026-11-01 12:18:01 -05:00
Brandon Thomas 40d99e3068 Merge PR #3038 2026-11-01 12:18:01 -05:00
prxnvt 4fbd1dc337 Engine: a Hidden cell on a protected sheet hides its Python code
A cell formatted Hidden on a protected sheet hid its formula but not its
Python code: display::formula_hidden only looked for a formula at the cell (a
Python cell was covered only while its result spilled), and the formula bar
had its own check for formulas alone. Both now treat Python code as they treat
a formula, so the formula bar shows nothing for a hidden Python cell, as it
does for a hidden formula. Editing the cell is refused as before, because it
is locked.
2026-11-01 12:16:05 -05:00
prxnvt 58aa00bae4 Docs: xl() references in Python code stay put when a sort moves the cell 2026-11-01 12:11:23 -05:00
prxnvt 14ab0f8ee3 Engine: Replace edits a Python cell's code and keeps it a Python cell
Replace matched a Python cell on its code and then rebuilt the cell from the
replaced text as if it had been typed, so the cell became a text cell holding
the edited code and the Python cell was lost. The replacement now edits the
code: the cell stays a Python cell with its return type, its result is dropped
(#BLOCKED!, as for any changed code) and a spill it had is cleared. Code that
would no longer fit in a PY formula is left unchanged and counted in a new
skipped field of the result.
2026-11-01 12:10:31 -05:00
prxnvt 2d56f1b936 Engine: a macro's check for writes over data keeps a Python cell
The footprint dry run and the per-step guard of macro runs (new on main)
compared formulas and values only. A step that replaced a Python cell with
its own cached value, or with other code that also shows #BLOCKED!, was not
counted as a write over data, so the code could be lost without the
question. A step that only blocked a Python cell's spill, which leaves its
code as it was, was counted as one. Python code is now content as a formula
is: a change of code or return type is a write, and a change of the cached
value alone is a recalculation.
2026-11-01 12:09:50 -05:00
prxnvt c03c61fd05 Merge main into Python cells as data 2026-11-01 12:00:09 -05:00
scks1 60a267b4ef #3057: select visible workbook views and normalize tab ratios 2026-11-01 08:06:44 -05:00
vioreltusandClaude Opus 5.5 5e415cdd34 macOS: Intel Macs skip wgpu's indirect-call validation shaders (fixes #2812) (#3039)
* Fix #2812: Intel Macs skip wgpu's indirect-call validation shaders

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 11:37:17 +01:00
AnhNgoo 513f19ded3 fix(ui): show macOS group shortcuts in command palette 2026-10-31 15:14:23 +07:00
prxnvt c5a80ff791 UI: Insert Function lets Tab reach the category list
The search box asked for the keyboard focus every frame, so Tab moved to the
category list for one frame and the focus went back to the search box. The
dialog now gives the search box the focus once, when it opens, through the
module's take_first_focus, as the other dialogs do.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:14:20 +01:00
Harrison Booth 3de6cb1a3d gc-fix-2657: tighten PERCENTRANK boundary tolerance 2026-10-30 16:09:50 -05:00
Harrison Booth 75d4de230b Merge remote-tracking branch 'upstream/main' into gc-fix-functions
# Conflicts:
#	docs/function-parity.md
2026-10-30 16:03:03 -05:00
Harrison Booth fbff1e301c gc-fix-2657: isolate PERCENTRANK parity fix 2026-10-30 16:01:37 -05:00
Harrison Booth 5c31d7a17e gc-fix-2657: isolate MOD parity fix 2026-10-30 16:01:08 -05:00
Harrison Booth 22b6dfe8d7 gc-fix-2657: split YEARFRAC parity change 2026-10-30 16:00:26 -05:00
Harrison Booth b612141726 xlsx: keep intl date functions in xlfn list 2026-10-30 15:56:52 -05:00
Harrison Booth c82a138462 M2.1: fix XLS style test clippy lints 2026-10-30 15:47:18 -05:00
Harrison Booth 591c6ed6f9 Fix XLS style test Clippy complexity 2026-10-30 15:40:56 -05:00
Harrison Booth 9587f08253 Merge upstream main into gc-xls-cell-styles 2026-10-30 15:31:49 -05:00
Brandon Thomas 75f7056e1b Review fixes: keep Plutonium-fream's contributor entry, order ui-parity changelog 2026-10-30 15:42:48 -04:00
Brandon Thomas d0cc17852e Merge PR #2919
# Conflicts:
#	docs/ui-parity.md
2026-10-30 15:42:31 -04:00
Brandon Thomas d43baa2ceb Merge PR #2896
# Conflicts:
#	contributors/people.toml
2026-10-30 15:42:31 -04:00
Brandon Thomas 15ea238fb7 Merge PR #2883
# Conflicts:
#	contributors/people.toml
#	docs/ui-parity.md
2026-10-30 15:42:31 -04:00
Brandon Thomas b2f984a884 Merge PR #2627 2026-10-30 15:42:31 -04:00
Brandon Thomas 4afcaacd44 Merge PR #2620 2026-10-30 15:42:31 -04:00
Brandon Thomas 885a5dd6bc Merge PR #2970 2026-10-30 15:42:31 -04:00
Brandon Thomas 39020af9e9 Merge PR #2955 2026-10-30 15:42:31 -04:00
Brandon Thomas 7abaeba33c Merge PR #2888 2026-10-30 15:42:31 -04:00
Brandon Thomas ecd72eccc4 Merge PR #2851 2026-10-30 15:42:31 -04:00
Brandon Thomas ea0da03e12 Merge PR #2817 2026-10-30 15:42:31 -04:00
Brandon Thomas 569fb8b4c6 Merge PR #2795 2026-10-30 15:42:31 -04:00
Brandon Thomas dec916081a Merge PR #2794
# Conflicts:
#	docs/function-parity.md
2026-10-30 15:42:30 -04:00
Brandon Thomas da3e38750a Merge PR #2769 2026-10-30 15:42:10 -04:00
Brandon Thomas 3e4a2ccc9a Merge PR #2726 2026-10-30 15:42:10 -04:00
Brandon Thomas 27d5930d6b Merge PR #2708 2026-10-30 15:42:10 -04:00
Brandon Thomas 412571bc28 Merge PR #2670 2026-10-30 15:42:10 -04:00
Brandon Thomas 6f77f68fbc Merge PR #2666 2026-10-30 15:42:10 -04:00
Brandon Thomas c305315610 Merge PR #2719 2026-10-30 15:42:10 -04:00
Brandon Thomas e4d8aae522 Merge PR #2870 2026-10-30 15:42:10 -04:00
Brandon Thomas 92256075f2 Merge PR #2722 2026-10-30 15:42:10 -04:00
Brandon Thomas 9e2d5ef17f Merge PR #2718 2026-10-30 15:42:10 -04:00
Brandon Thomas 559a0369c3 Merge PR #2713 2026-10-30 15:42:10 -04:00
Aimar Mendez 1d38a25c8d macOS menu test: use the whole native menu, so clippy passes on a Mac
e6df6f42 removed the #[allow(unused_imports, dead_code)] on the
native_menu module that tests/mac_menu_appkit.rs builds in. That test
only compiles on macOS, so `cargo xtask ci` has failed at clippy on
every Mac since, while the Linux gate stayed green:

- the test called only `install`, leaving `poll`, `sync_windows`,
  `select_theme`, `WINDOW_ITEM` and most `NativeMenu` fields unused;
- the harness = false build has cfg(test) but drops #[test] functions,
  so the unit tests' `use super::*` was an unused import.

The test now also polls the menu with a one-workbook session and checks
that the Window menu lists that workbook (#716), which uses the rest of
the module. The unit tests name `super::` items instead of a glob import.

Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with
CRAFT_FONTS_DIR at the gate's craft-fonts commit), and the new check
fails when the poll is removed (Window menu's last item "" vs "Book1").
2026-10-30 18:19:50 +00:00
MarekSpodBiedry 6f067cce8a Refusal to shift cells off the worksheet names GridCraft, not Excel (#2941) 2026-10-30 14:59:45 +01:00
Dylan M. Taylor 68a4b1a1c3 UPD.1: honor app pretend versions in update checks (#2934) 2026-10-30 02:29:28 -04:00
Dylan M. Taylor f6bcd77b01 Update prompt shows the app icon and accent color instead of a letter (#2928)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1f9d55, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:55 -04:00
Dylan M. Taylor 400e5acd6c CI: workflows only call craft-common-actions at @main (#2921)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:10:15 -04:00
KameronandClaude Fable 5.1 7d38697f71 Test: Command released in the same frame as the arrow press still jumps
Covers the case the fix exists for: an automation keystroke presses and
releases within one frame, so the frame-wide modifier state is already
empty and only the press event carries Command. With the modifiers read
from `i.modifiers` this assertion fails (B3 instead of B1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 15:52:37 -07:00
Dylan M. Taylor 39a10b1342 packaging: Flatpak finds and prints to CUPS printers (#2913)
* packaging: Flatpak finds and prints to CUPS printers

The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.

* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)

Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.

* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path

Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.

* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted

Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.

* packaging: never redirect a CUPS_SERVER the sandbox was given

Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.

* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail

Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
2026-10-29 18:48:40 -04:00
KameronandClaude Fable 5.1 57e40d2d14 Formula pointing: Ctrl+arrow and Ctrl+Shift+arrow jump to the edge of the data
While a formula reference is being pointed from the keyboard, Ctrl+arrow
(⌘arrow on a Mac) moved the pointed cell by one, and Ctrl+Shift+arrow
extended the pointed range by one cell, so `=COUNTIF(`, a click on the top
of a column and ⌘⇧↓ pointed at two cells instead of the column of data.
Both now go to the edge of the data region, as the same keys do on the
selection outside an edit: `point_move` takes `jump` and uses the engine's
`selection::jump`, which Ctrl+arrow on the grid already uses, so the two
agree (last filled cell of a run, else the next filled cell, else the sheet
edge). A pointed range that reaches the sheet edge is written as whole
columns or rows (`A:B`), as Excel writes it.

The editor used to read Shift from `i.modifiers`, the modifier state when
the frame's input was collected; an arrow pressed and released within one
frame (a control client's `ui.key`, an automation tool's keystroke) had
already lost it. It now reads the modifiers carried by the arrow's own key
event, as the grid's keyboard handler does.

Test: ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data (down to the
last filled cell, right across the data, down to the sheet edge, Ctrl+Up
without Shift to the next filled cell, then a plain arrow).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 15:11:55 -07:00
skylar taylor-barrick 6ba1e19857 Merge pull request #1422 from switch87/fix/1419-clear-skips-filtered-rows
Clear: Delete and the Clear commands skip the rows a filter hides (#1419)
2026-10-29 14:18:46 -06:00
fream 9c6de8abde fix(ui): keep chart source outlines below objects (#2861) 2026-10-29 16:49:42 +01:00
Roman Lisunov 0688e83752 Merge upstream main and retain numpad insertion documentation 2026-10-29 14:45:20 +02:00
NetterSilverandClaude Haiku 5.5 b004335538 Keep Fill Series value equal to stop despite float drift
Found while testing crates/engine/src/fill.rs:595-599

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:33:30 +00:00
fream 45cc92ae3c fix(ui): prefill existing hyperlinks in link editor (#2876) 2026-10-29 11:49:33 +01:00
truongitandClaude Opus 5.5 94433a3c4f Document the Agile decryption steps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:51:55 +07:00
Dylan M. Taylor 2ff751791b Merge pull request #478 from prxnvt/pr/macros-ui
Macro interface: Automate › Macros, Macros and Record Macro dialogs, step editor with a debugger
2026-10-29 05:10:50 -04:00
truongitandClaude Opus 5.5 9e0f14cab9 Open Excel's password-protected workbooks again: accept a hash-size integrity key (#2712)
Since #2425 checks Agile encryption's dataIntegrity, every workbook Excel
encrypts fails with "the decrypted data failed its integrity check". The
reader keyed the HMAC with the decrypted encryptedHmacKey cut to the salt
size (16 bytes), as [MS-OFFCRYPTO] 2.3.4.14 describes it, but Excel's key
is as long as the hash: 64 bytes for SHA-512. The check now accepts a key
of either length, so spec-literal writers still open and a modified
package still fails.

The test encryptor writes the integrity key as Excel does, so every
encrypted fixture now has Excel's layout; a new test covers both key
lengths, a key of neither length, and tampering with each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:37 +07:00
Dylan M. Taylor 355840a2a2 Merge branch 'prxnvt/pr/macros-ui' into stack/record
Brings in the macro interface: the Macros dialog and editor, the
Automate ribbon section, drag-and-drop of macro steps, and the
macro overlay on the grid. Ported into the split module layout on main.

Clippy gate: moved wildcard imports to explicit lists, named the
test edit tables, boxed the overlay layer tuple, switched the editor
to craft_ui::ComboBox per clippy.toml, and simplified an is_some_and
in the LP solver.
2026-10-28 23:07:55 -04:00
AnhNgoo bc1e19f1f6 docs(grid): document cell painting and clipping behavior 2026-10-29 10:02:14 +07:00
AnhNgoo d1f063636a test(grid): preserve clipping for fully fitting wrapped text 2026-10-29 09:51:22 +07:00
AnhNgoo 6af06e9d30 fix(grid): preserve full-cell clipping for fitting wrapped text 2026-10-29 09:38:42 +07:00
AnhNgoo f6b900dd5a Merge upstream main and preserve wrapped text clipping fix 2026-10-29 09:18:43 +07:00
Dylan M. Taylor 3fc35e5d3b Merge branch 'stack/debug' into stack/record
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 22:15:51 -04:00
Dylan M. Taylor 3923ab3dca Merge branch 'stack/check' into stack/debug
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 22:08:49 -04:00
Dylan M. Taylor 70bcab56e2 Merge branch 'stack/run' into stack/check 2026-10-28 22:04:06 -04:00
Dylan M. Taylor 708481b615 Merge branch 'stack/tokens' into stack/run
# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/insert.rs
2026-10-28 22:03:59 -04:00
Dylan M. Taylor a1a491b10c Merge branch 'stack/engine' into stack/tokens 2026-10-28 21:59:06 -04:00
Dylan M. Taylor 7bf8ed7495 Merge remote-tracking branch 'origin/main' into stack/engine
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 21:59:05 -04:00
AnhNgoo 9e23c98f3a test(grid): address CodeRabbit feedback for wrapped text clipping 2026-10-29 07:53:37 +07:00
Harrison Booth f01c02cc41 fix: place AutoSum caret inside empty formula 2026-10-28 15:16:55 -05:00
Dylan M. Taylor 146161b4b4 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#2847)
* Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds

Cross-built FreeBSD aarch64 archives were not possible: package.sh took the
arch from uname -m (the x86_64 VM host), built for the host, read binaries
from target/release, and ran the aarch64 gridcraft-cli to print its version.

When CARGO_BUILD_TARGET is set, build with --target, read from
target/<triple>/release, name the archive by the target's arch (aarch64 for
aarch64-unknown-freebsd, x86_64 otherwise), and skip running the binary.
Unset behavior is unchanged.

* Reject unsupported CARGO_BUILD_TARGET in FreeBSD packaging

Only x86_64-unknown-freebsd and aarch64-unknown-freebsd are valid; any other
value now fails before the build or packaging.
2026-10-28 15:35:58 -04:00
Dylan M. Taylor 70327cd3d9 Grant network access so update checks can reach GitHub (#2841) 2026-10-28 13:07:26 -04:00
AnhNgoo f9b869b3a4 fix(grid): prevent partial wrapped text lines in fixed-height rows 2026-10-28 17:03:09 +07:00
Daniel 44c3a36ea1 Merge upstream main and refresh Wayland decoration dependencies
Keep the Linux Adwaita no-title feature with X11 fallback; preserve
non-Linux Unix targets, regenerate the merged lockfile entries, and
retain the dependency's MIT notice without duplicate package installs.
2026-10-28 10:33:31 +01:00
AnhNgoo c22f46628c fix(desktop): load preferences before creating startup workbook 2026-10-28 10:47:42 +07:00
AnhNgoo dd48d09605 test(ui): cover runtime tooltips and current region shortcut 2026-10-28 10:31:19 +07:00
AnhNgoo a81cb0f21e test(engine): cover default workbook author and preservation 2026-10-28 10:22:31 +07:00
AnhNgoo 712f45965b fix(ui): share verified shortcuts between palette and ribbon 2026-10-28 10:07:56 +07:00
Harrison Booth ff7ed4b57c Move the ECMA.CEILING revision entry into the revision history table 2026-10-27 21:28:25 -05:00
Harrison Booth 3b73ac9b91 Document ECMA.CEILING as not on Microsoft's list 2026-10-27 21:21:14 -05:00
Harrison Booth f6015a7e6e Pin COPILOT as written without an _xlfn. prefix 2026-10-27 21:21:04 -05:00
Harrison Booth 87342cb308 HYPERLINK tests: use the Arg helpers 2026-10-27 21:08:35 -05:00
Harrison Booth 0f78a6b126 HYPERLINK without a friendly name returns the link as text
Excel: HYPERLINK(1) is the text "1" (ISTEXT is TRUE); with a friendly
name the result keeps that value's type.
2026-10-27 21:08:23 -05:00
Harrison Booth 28a8e67e7a functions: add ECMA.CEILING (CEILING under a second name) 2026-10-27 20:57:33 -05:00
Harrison Booth 25816f0009 xlsx: write _xlfn. for STOCKHISTORY, TRANSLATE, DETECTLANGUAGE; none for NETWORKDAYS.INTL, WORKDAY.INTL 2026-10-27 20:57:17 -05:00
AnhNgoo bf4a3969e8 fix(ui): show verified selection shortcuts in command palette 2026-10-28 08:56:28 +07:00
AnhNgoo f77d70502a fix(ui): verify command shortcuts for runtime OS 2026-10-28 08:39:37 +07:00
AnhNgoo 6e7fb6ce0b fix(ui): correct Paste Special shortcut in command palette 2026-10-28 08:13:33 +07:00
Dmussi 8b35f2ac12 Format status bar label and translate its tooltip 2026-10-27 19:20:18 -04:00
RomanLis91andClaude Opus 5.5 a8e03ef40f fix(keyboard): Ctrl with the numpad's + inserts rows, columns or cells, as in Excel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJaF5jevKidVoJcnTyBFub
2026-10-27 23:40:35 +02:00
Harrison Booth 66f8b05fc2 Merge upstream main into gc-fix-functions
# Conflicts:
#	crates/functions/src/math.rs
#	crates/functions/src/stat.rs
2026-10-27 16:17:07 -05:00
Harrison Booth 0e643e9026 Merge remote-tracking branch 'upstream/main' into gc-xls-cell-styles 2026-10-27 15:59:33 -05:00
AnhNgoo d3832e98e6 fix(ui): show shortcuts in command palette (#2725) 2026-10-27 16:56:49 +07:00
AnhNgoo fa6ccc6ae5 fix(engine): set default workbook author (#2448) 2026-10-27 16:10:46 +07:00
Higgs32584andClaude Opus 5.5 a38aed68cb Security: control channel enforces its 10 s token deadline and caps waiting connections (#2717)
authenticate() set the read timeout on the writing half of the socket while it read the
token through a try_clone; on Windows the clone has its own timeout, so a peer that never
sent a line held its thread forever (and a per-read timeout let a byte-at-a-time peer
stretch it elsewhere). The first line now has one total deadline, set on the handle being
read and shortened before each read. At most 16 connections may wait to authenticate (more
are closed on accept, as #2174 suggested), and accept errors back off instead of spinning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:55:44 -04:00
Higgs32584andClaude Opus 5.5 7d828c01f7 Security: cap the work of lifted lookups, needles x lookup vector, with #NUM! (#2716)
A lookup lifted over an array of needles searched its whole lookup vector once per needle
with no total cap, so =ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576))) in a 1.6 KB file
ran for hours and hung file.open. VLOOKUP, HLOOKUP, LOOKUP, MATCH, XLOOKUP and XMATCH now
return #NUM! past 5e8 needle x entry steps, the pattern MMULT uses. Other lifted functions
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:40:18 -04:00
Higgs32584andClaude Opus 5.5 4cc2c44678 Security: web links open with noopener as a window feature, not in the window name (#2715)
window.open(url, "_blank,noopener") named the new window "_blank,noopener" and passed no
features, so a page opened from a workbook link kept window.opener and could navigate the
editor tab. Pass "noopener,noreferrer" as the features argument instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:39:40 -04:00
Higgs32584andClaude Opus 5.5 ea5292c94a fix(engine): gate the temp-name symlink test to unix
`save_fails_upfront_when_the_temp_name_is_taken` calls
`std::os::unix::fs::symlink` but was only gated on non-wasm, so the
engine crate's lib tests did not compile on Windows. Gate it `cfg(unix)`
like the neighbouring permissions test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:09:30 -04:00
Harrison Booth 0edf2b850a Fix the spill test: clone the workbook out of its Arc (#2053) 2026-10-26 17:34:04 -05:00
Harrison Booth 2b6e088a6d Engine test: copying a sheet that holds a spill matches a full recalc (#2053) 2026-10-26 17:29:12 -05:00
Harrison Booth acc32227cb IMSQRT: match the zero real part by pattern (clippy redundant_guards) 2026-10-26 13:03:51 -05:00
Harrison Booth 1fadf98a10 IMSQRT: reproduce Excel's stray real part for negative reals
Excel computes IMSQRT through the polar form, so IMSQRT("-4") is
1.22514845490862E-16+2i rather than 2i. Tests assert strings read back
from Excel; the function-parity doc lists the remaining IM* differences.
2026-10-26 12:58:54 -05:00
Harrison Booth f4f48f3b23 CSV export values: choose the date or time shape from the format section that formats the value 2026-10-26 12:51:42 -05:00
Harrison Booth 9595fc8ee7 CSV export values: pick the ISO shape from the format's date and time parts; guard the file test on wasm32 2026-10-26 12:42:23 -05:00
Harrison Booth 9515c29969 CSV export: file.exportCsv values:true writes raw values for automation (#2648)
The default stays Excel's displayed text, byte for byte.
2026-10-26 12:34:18 -05:00
Harrison Booth c72f5a49b0 PERCENTRANK test: check the fifteenth digit with a tight tolerance 2026-10-26 11:48:54 -05:00
Harrison Booth 5ed5c09bc6 NUMBERVALUE: a leftover dot is a decimal point, not a date separator 2026-10-26 11:39:22 -05:00
Harrison Booth a75b19e678 MOD test: use the literal Excel reads (15 significant digits) 2026-10-26 11:32:08 -05:00
Harrison Booth 551ec6590d Fix literal grouping in the MOD test 2026-10-26 11:25:20 -05:00
Harrison Booth 9cc126409b Match Excel for YEARFRAC basis 0, MOD, NUMBERVALUE and PERCENTRANK edge cases
Checked against Excel 16: YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD gives #NUM! once the quotient magnitude reaches 1.1259E12; NUMBERVALUE no longer rejects unmatched separators; PERCENTRANK gives #DIV/0! from significance 309 and 15 significant digits from significance 15.
2026-10-26 11:15:00 -05:00
prxnvt 1d7206ebdd Docs: Python cells as data (architecture, file formats, functions, roadmap)
architecture.md gains a Python cells section: the model type, the text
forms and the size limit, the xlsx Python part, calc laying out kept
results as graph nodes, and the limits on open. file-format-parity.md
gains a Python cells table (xlsx as [MS-XLSX] 2.3.10 specifies, the
formula naming its code in the Python part, with cached values and
spills; other formats; GridCraft JSON; the limits on open; and
rich-value object results as not kept). function-parity.md marks the
four Python errors done with their ERROR.TYPE numbers and adds a Python
in cells row (stored, not run). ROADMAP.md records the progress. The
return types are written as "0 or 1" so no table cell holds an
unescaped pipe. architecture.md's "Last updated" label loses the space
inside its bold markers.
2026-10-26 10:18:38 -04:00
prxnvt fcbbfa5142 XLSX: Python cells as _xlfn._xlws.PY() formulas, code in the Python part, cached values; opening limits
A Python cell is written to xlsx as [MS-XLSX] 2.3.10 specifies: its
formula is _xlfn._xlws.PY(i,0|1), where i is the zero-based index of the
cell's script in the workbook's Python part and 0|1 the return type, 0
for an Excel value and 1 for a Python object. The part (2.1.23) is
xl/python.xml, content type application/vnd.ms-excel.python+xml,
related from the workbook part with
http://schemas.microsoft.com/office/2023/09/relationships/Python; its
root python holds pythonScripts, one pythonScript per Python cell in the
order the cells are written, with the code in code. The code keeps its
xl() references as text, so the formula has no further arguments. The
cell keeps its cached value. A spilling result is written like a dynamic
array (cm="1", t="array" ref=...) with the spilled values cached, so
other applications show the values (and #NAME? if they recalculate).

The reader turns a PY( formula with or without the _xlfn. and _xlws.
prefixes into a Python cell. An index names a script of the Python part,
and each %Px% in the script becomes the text of the formula's argument x
(2.3.10.4). The string-literal form, PY("chunk"&"chunk",0|1), still
reads, from the formula's own text so line breaks in the code survive. A
formula naming a script the part doesn't hold keeps only its cached
value, with a warning: no calculation can evaluate it. The reader keeps
the cached spilled values as the cell's result, so a workbook from Excel
shows its arrays without running anything. Python errors are cached as
#VALUE!, as #SPILL! and #CALC! already are. Native JSON keeps Cell.py
whole: code, return type, the last result and the exact error.

Calc lays out a Python cell's kept result: Python cells are nodes of the
dependency graph that read nothing, and a pass takes a dirty Python
cell's result instead of evaluating it, so its array spills, is blocked
(#SPILL!) and unblocked, and feeds its dependents exactly like a
formula's. An array that is malformed or has more than 1,000,000 cells
(only a crafted file holds one) shows #SPILL!. A dirty Python cell is
pending in the pass, as a dirty formula is, so a formula, a range read
or a cell-by-cell range walk that reaches it before its turn lays it out
first rather than reading its old value.

Opening a file can't make the app loop over a claimed size: the reader
keeps a Python cell's spill range only when it has at most 1,000,000
cells (a range claiming the whole sheet is dropped with its values, as a
formula's spilled values are), and io::adopt_python rebuilds results for
at most 4,000,000 cells over the whole workbook, with a warning for the
cells it skips. The Python part is read up to 64 MB and 1,000,000
scripts, a script is kept up to one character past what a cell may
hold, and the cells take at most 64 MB of code from the part in all,
since several formulas can name one script. io::enforce_python_limits
drops the code of a cell whose code doesn't fit in 8,192 characters as
PY(...) (the cell keeps its cached value, with a warning) and a kept
result that isn't a well-formed array within the cap, each with its
spill range and spilled values, so the sheet and a save no longer show
the old spill. Formats that keep PY() as an ordinary formula (xls, xlsb,
ods) are adopted the same way. Opening a file runs nothing.

The command-sequence fuzzer sets Python cells (code with xl()
references, quotes, line breaks, and code at and over the size limit),
and its xlsx round-trip check allows the Python errors becoming
#VALUE!, which is what the file format stores.

Tests: the written formula naming its script, the Python part with its
content type and relationship, return types, a spilling table saved as a
dynamic array, Python errors as #VALUE!, the round trip of values, code,
long code with quotes and line breaks and the error, and no Python part
without Python cells; a workbook laid out as the format specifies (an
index, a parameter named as %P2%) read as Python cells with their
spilled values and saved back with its code in a Python part; the
string-literal form; a formula naming a missing script, with and without
a Python part; 20,000 cells naming one long script against the code
budget; the part's reader and writer (escaping, odd %P tokens, a long
script, a repeated parameter); a blocker in the spill area giving #SPILL!
and clearing it spilling again; the native JSON round trip; a spill
range claiming the whole sheet; the workbook-wide budget; hostile JSON
(code over the limit, a result claiming far more cells than it holds); a
malformed result that never spills; PY() formulas at 8,079 and 8,080
characters of code. Calc: formulas, MAX, SUM, COUNTA and SUMPRODUCT read
a dirty Python cell whose array is blocked before its turn, in a full
and an incremental recalculation, and see the array once it is
unblocked; enforce_python_limits removes the spill of dropped code and
of a dropped result, and keeps a sound one.
2026-10-26 10:18:38 -04:00
prxnvt c6c54c57e6 Engine: python.set makes a Python cell; this build stores code and
doesn't run it

python.set {cell?, code, output?: object|value} makes the cell (the
active cell by default) a Python cell, keeping its style and taking a
formula and its spill away. `output` is the return type Microsoft
documents for PY(): an Excel value (the default) or a Python object;
without it a Python cell keeps the one it had. The code must fit in
Excel's 8,192-character formula once written as PY(...), quotes doubled
and literals joined, so the command checks the encoded formula rather
than a character count. A table's header row refuses Python (it holds
the column's name, as Excel keeps formulas out of headers), and a locked
cell on a protected sheet refuses it through the usual protection check.

Nothing runs Python here. Unchanged code keeps its cached result and
value; new or changed code shows #BLOCKED!. One undo step takes the cell
back.

Sheet::clear_spill removes the spilled values inside a range with work
bounded by the values stored, not by the range's size.

In the interface, F2, a double click or the formula bar on a Python cell
edits its code, and a changed text is committed through python.set,
which keeps the return type; through cell.set it would have replaced the
Python cell with an ordinary cell holding the code as text. Typing over
a Python cell replaces it, as typing over a formula does, and committing
the code unchanged writes nothing. Deleting all the code clears the
cell through cell.set, as an empty entry clears any other cell; code
of spaces is still code.

Tests: the code and return type in cell.get, #BLOCKED! for new code,
cached results kept for unchanged code, the active-cell default, undo,
refused params (missing or non-text code, a bad cell, a bad return
type), the encoded limit at 8,079 and 8,080 characters and with doubled
quotes, a table header, a protected sheet, and a formula's spill taken
away. A UI test edits a Python cell's code with F2 and Enter, commits it
unchanged, and types over it; another clears a Python cell by deleting
all its code, keeps code of spaces, and undoes the clearing.
2026-10-26 10:18:38 -04:00
prxnvt 44c663aea2 Model: Python cells, and the #PYTHON!, #BUSY!, #TIMEOUT! and #BLOCKED! errors
Cell gains `py: Option<Arc<PyCode>>`, read with a serde default and not
written for ordinary cells, so older native files load and ordinary
cells serialize as before. PyCode holds the code, the return type (an
Excel value or a Python object), the last result (value, type name,
preview, whether it converted to cells) and a stale flag for manual
calculation. The cell's `value` stays the cached result, so formulas,
display and saving read a Python cell like any other. is_blank,
CellStore::has and input_text (which returns the code) take the code
into account; Cell::has_content and clear_content cover value, formula
and code together.

The model reads and writes the PY function's syntax as Microsoft
documents it, PY("chunk"&"chunk",0|1), with string literals of at most
255 characters and the return type 0 for an Excel value, 1 for a Python
object. The reader takes the `_xlfn._xlws.` prefix (and `_xlfn.` or
`_xlws.` alone, in any letter case); to_file_formula writes it. (An xlsx
file keeps the code in its Python part instead; the xlsx commit adds
it.) The size limit is Excel's 8,192 characters for that formula, not a
count of code characters: doubled quotes and the "&" between literals
count, so code with many quotes fits in fewer characters (8,079
characters of plain code fit exactly). The model also finds the
xl("...") literals in code, skipping comments and other strings; a plain
literal may escape a quote or a backslash. A rewritten reference that
holds the literal's quote (a sheet name that needs quotes, 'Q4
2026'!B2) takes the other quote as the literal's delimiter, or an
escaped quote when it holds both, so it never ends the string early.

Edits: cell.set over a Python cell, Clear Contents, Clear All and the
cells a merge empties remove the code; Clear Formats keeps it. Copy and
paste (All, Formulas), Fill, sort, cut-move, sheet copy and undo carry
it; a cut to another sheet first qualifies its xl() references with the
source sheet, as a moved formula's references are, so they keep
reading the same cells. Paste Values and Paste Link do not carry it,
and paste operations do not combine numbers with a Python cell. Fill
copies Python cells instead of extending them as a series. cell.set
with =PY(...) is refused with a message that points to python.set, and
Text to Columns keeps a piece that reads as =PY(...) as text instead of
clearing it. Goal Seek refuses a Python cell as its changing cell, and
Solver as a variable cell, as both refuse formulas: the number written
there would replace the code. The sheet-protection check compares the
code, so a locked Python cell cannot be changed. cell.get reports
{python: code, output, typeName, preview, stale}.

The xl() literals follow reference-moving edits: rewrite_all_formulas
(row and column insert and delete, cell insert and delete, cut-move),
sheet rename and sheet delete adjust each literal like a formula
reference, and replace it only when it changed.

Errors: CellError gains Python, Busy, Timeout and Blocked, and ALL has
14 entries, so the lexer reads them as literals. ERROR.TYPE gives 11 for
#BLOCKED!, the number Excel returns. Microsoft documents no ERROR.TYPE
number for #PYTHON!, #BUSY! or #TIMEOUT!; they take 19, 20 and 21, after
the numbers Excel is known to use. The xlsx writer caches the four as
#VALUE!, as it already does #SPILL! and #CALC!, because they are not
error codes of the file format.

Tests cover the =PY refusal, removal by entry and clearing, undo, copy,
paste values, fill and sort, xl() rewrites through row insert, sheet
rename (also to names that need quotes, with one quote and with both),
cut-move (also to another sheet) and column delete, Goal Seek and
Solver refusing Python cells, Text to Columns keeping a =PY piece,
native JSON round trips, the new errors, the prefixes, escaped quotes in
literals, and the encoded size limit at 8,192 characters exactly and
with doubled quotes.
2026-10-26 10:18:38 -04:00
Florent Siegenthaler 8cfe970ff8 Declare the UI languages to AppKit so the open and save panels are translated 2026-10-26 09:24:52 +01:00
prxnvt e40291fc3d Macro interface: follow per-window dialogs and egui 0.37 accessibility
Main now keeps a dialog with its own workbook window, so the step editor is
parked with its workbook rather than drawn over another one, and closing that
workbook drops the editor without drawing it again: forget_closed_editor
clears it each frame. The Macros, Record Macro and editor text boxes are
labelled by their captions, and the tests pick the question's Cancel and the
chart menu's Delete by role, as the formula bar and ribbon share those names.
2026-10-26 00:38:32 -04:00
prxnvt c801e50e40 Merge #2152 (f8e1f8df, with main at f6b0cec6) into #478
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/io.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-26 00:32:55 -04:00
prxnvt f8e1f8dfc0 Merge #2145 (4d26e395, with main at f6b0cec6) into #2152
# Conflicts:
#	crates/engine/src/io.rs
#	crates/engine/src/lib.rs
#	docs/target-app-parity.md
2026-10-26 00:29:13 -04:00
prxnvt 4d26e395fc Merge #2550 (d4096eee, with main at f6b0cec6) into #2145
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-26 00:26:35 -04:00
prxnvt d4096eee5a Merge #2144 (34eee860, with main at f6b0cec6) into #2550 2026-10-26 00:24:57 -04:00
prxnvt 34eee86053 Merge #2151 (6781aa49, with main at f6b0cec6) into #2144
# Conflicts:
#	crates/engine/src/io.rs
#	crates/engine/src/lib.rs
2026-10-26 00:24:11 -04:00
prxnvt 6781aa4927 Merge main (f6b0cec6) into #2151 2026-10-26 00:21:54 -04:00
prxnvt 483276f719 Merge #465 (3772f2c6, with main at 42bbf6ba) into #2151 2026-10-26 00:21:42 -04:00
prxnvt 3772f2c61a Macros: write the macro names into xlsx again after the main merge
The merge with main kept main's xlsx line in save_bytes_as, which dropped
macros::with_names, so an xlsx save carried no macros.
2026-10-26 00:21:00 -04:00
Pranav 1cd45cc909 Merge branch 'main' into pr/macros-ui 2026-10-25 23:54:09 -04:00
Pranav e85297e26c Merge branch 'main' into pr/macros-engine 2026-10-25 23:49:20 -04:00
Pranav b552b7a929 Merge branch 'main' into pr/macros-record 2026-10-25 23:49:08 -04:00
Pranav 910b50c1dd Merge branch 'main' into pr/macros-run 2026-10-25 23:48:13 -04:00
Dmussi 848b0e658a Click a status bar statistic to copy its value 2026-10-25 23:24:26 -04:00
Daniel 0cb1745597 Merge latest upstream main and resolve GNOME Wayland control conflicts 2026-10-26 00:00:39 +01:00
prxnvt 8fd6ec124b Engine: a macro shortcut is one letter, alone or after Ctrl+Shift
`parse_shortcut` took the last letter of any spelling, so `"Ctrl+Alt+M"` was saved as
Ctrl+Shift+M. It now takes a bare letter or one after Ctrl+Shift, Cmd+Shift or ⌘⇧, and refuses
other modifiers.

- Test: `shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones`.
2026-10-25 06:48:57 -04:00
prxnvt 299beb5085 Merge #2145 (bf6a8520) into #2152
# Conflicts:
#	crates/engine/src/record.rs
2026-10-25 06:48:57 -04:00
prxnvt ffacf7405f Docs: Record Macro's shortcut example uses a letter a macro may take
The example was K, which GridCraft uses (Group), so a caller who copied it was refused; it is M
now, in the command's description and on the macro page.
2026-10-25 06:48:57 -04:00
prxnvt 59d85ca138 UI: the macro editor refuses a taken name in the person's words
Saving over a macro now needs `replace: true`, and the engine's refusal says so in terms for
programs. A new draft whose name another macro has (or a kept one from a newer version) is
refused by the editor first, with "A macro called “three” already exists. Choose another name."

- Test: `a_new_macro_never_replaces_one_with_its_name`.
2026-10-25 06:48:57 -04:00
prxnvt ea5eb2ae3c UI: Macro Options saves over the macro it changes
Saving a macro over one with the same name now needs `replace: true`, so Macro Options, which
changes the shortcut and description of a macro that exists, failed to save. It passes
`replace: true`; `options_set_a_shortcut_that_runs_the_macro` covers it.
2026-10-25 06:48:57 -04:00
prxnvt c442da9221 Engine: stopping a recording says when it left the chosen shortcut off
Record Macro checks the Ctrl+Shift letter when recording starts, but
another macro can take it before recording stops; the save then leaves
the shortcut off, and nothing said so. The result now names it in
shortcutDropped, the toast after Stop says the macro was saved without
it, and the macro page describes it.
2026-10-25 06:48:57 -04:00
prxnvt c142c70dbe UI: the macro editor keeps an input to a shape a macro may declare
The format now refuses a single-cell input with a header, several column types or more than one
row or column, a least above its most, and more column types than columns; the editor could make
each of these, and the save then failed.

- Switching an input to "single cell" sets one row and one column, takes the header off and keeps
  at most one column type; "Use current selection as example" does the same for one cell.
- Setting "at least" above "at most" moves "at most" up (and the other way round), and lowering
  the columns' "at most" drops the column types past it.
- Test: `an_edited_input_keeps_a_shape_a_macro_may_declare` (each result passes `check_limits`).
2026-10-25 06:48:57 -04:00
prxnvt 1e2492b4d6 Tests: the large-macro editor test reads the UI clock
Clippy refuses std::time::Instant::now, which traps on wasm; the test
that bounds the step list's frame time reads gridcraft_ui_egui::now_ms
instead.
2026-10-25 06:48:57 -04:00
prxnvt 60c20095e6 UI: the Macros dialog closes when another workbook becomes active
The dialog's rows and its Options pane hold the macros of the workbook
it opened on, but Run, Delete and Options act on the active workbook.
When an agent made another workbook active, the dialog stayed open over
it, and OK in Options would write the first macro's shortcut and
description onto the other workbook's macro of the same name. The
dialog now keeps its workbook and closes when another one becomes
active.
2026-10-25 06:48:57 -04:00
prxnvt a414b608d7 Docs: the macro page says where a question's answer runs, and its long lines wrap
The Questions entry says that an answer runs in the workbook that asked
and does nothing while another one is active. The lines the restack
joined in the recording paragraph and the Macros dialog entry are
wrapped again.
2026-10-25 06:48:57 -04:00
prxnvt a0fd599fe6 UI: the macro step editor reads each step in place
step_at copied the whole step list to look up one step, and the step
list calls it for every row on every frame, so a macro at the 10,000
step limit (which a recording can reach, or a file can carry) took about
ten seconds a frame. The lookup now walks the list without copying it:
five frames of that macro take well under a second. A test opens a
macro at the limit with a generous time bound.
2026-10-25 06:48:57 -04:00
prxnvt 0fdb4d03e4 UI: a macro question is answered only in the workbook that asked it
The Macros dialog's Delete question and the questions a macro run asks
(approve it, overwrite or move the data in the way) keep the command
and run it again with the answer. When another workbook became active
before the answer (an agent can switch workbooks under the prompt),
the answer ran there: Delete removed the other workbook's macro of the
same name. A question now keeps its workbook, and an answer given while
another one is active does nothing and says why.
2026-10-25 06:48:57 -04:00
prxnvt baf01c4211 UI: the macro step editor fits a narrow window
The editor's window had a minimum size of 660 by 380 and its toolbar
was one row of about 680 pixels, so in a window 600 pixels wide Run to
Here and Stop were off screen, and at 480 the inspector's tabs were cut
off. The window is now never larger than the app's window, the name row,
the toolbar and the inspector's tabs wrap (the toolbar's buttons are
disabled in place rather than in a scope of their own, which kept them
from wrapping), the inspector keeps room for its tabs, and its content
scrolls sideways instead of widening the window. Tests check the editor
at 600 and 480 pixels, and the Macros and Record Macro dialogs, which
already fit.
2026-10-25 06:48:57 -04:00
prxnvt f73e7b7e19 UI: closing the window asks about the macro step editor first
A window close (the close button, Alt+F4, quitting) let the app go when
every workbook was saved, losing the editor's unsaved steps, and with an
unsaved workbook it made that workbook active under the editor and asked
for a Save Changes question the editor's window would not let open, so
nothing happened. The close now waits for the editor: it asks about its
debug run and unsaved steps as its own close button does, Cancel keeps
the app open, and once the editor has closed the window close goes on
and asks about unsaved workbooks as before.
2026-10-25 06:48:57 -04:00
prxnvt 12072c5529 UI: a dialog asked for over the macro step editor says why it did not open
The step editor keeps its window until it closes itself, so a dialog
asked for while it is open is not shown. That was silent: File > Close
on a changed workbook asked for its Save Changes question and nothing
happened. The request now shows a message that says to close the
editor first.
2026-10-25 06:48:57 -04:00
prxnvt a18ecc81aa UI: the control channel's dialog cancel closes the macro step editor as its own close does
ui.dialog.cancel emptied the dialog slot, so with the step editor open
it went without its cleanup: the draft stayed behind, the Check overlays
stayed on the grid, the selection the reference boxes moved was not put
back and a debug run went on with no controls. The cancel now closes
the editor the way its close button does, and is refused, with the
reason, while the editor holds unsaved steps or a debug run, which only
its window asks about.
2026-10-25 06:48:57 -04:00
prxnvt a206178209 UI: the macro step editor works only on the workbook it opened in
The macro.* commands act on the active workbook, and the editor stays
open while another one becomes active (File > New, Switch Window, an
agent). A new draft saved then went into that other workbook, and Check,
Run and Debug ran there. The editor now keeps the workbook it opened in:
while another one is active it says so, its controls are disabled,
Save, Check, Run and starting Debug refuse with a message that names
the workbook, the grid overlays are hidden and closing does not put the
selection back on the wrong sheet. Closing the workbook closes the
editor.
2026-10-25 06:48:57 -04:00
prxnvt 73b0edd085 UI: the Macros dialog offers Trust Scripts for scripts from a file
The Macros dialog that replaces the scripts-only list keeps its Trust
Scripts row (#2178): scripts that came with an opened file are listed as
disabled until the row's button trusts them for the session, and Run
works after it. A test covers the row, the trust and the run, and the
macro page describes it.
2026-10-25 06:48:57 -04:00
prxnvt aa86b0c327 UI: merge fallout from the UI kit and the recorder heads
Icon buttons no longer carry an accessible name: the tests that came
with main since the branch was written (menu hover, popup dismissal, the
File menu, More Colors) find Copy, Save and Cancel by label, and the
Quick Access Toolbar and formula bar icons gave those labels twice. The
macro tests need the names of large, small and toggle ribbon buttons
only. The chart menu test takes the menu's Delete, which is drawn after
the ribbon's Home > Cells > Delete. The Trust Scripts row ported into
the Macros dialog is formatted.
2026-10-25 06:48:57 -04:00
prxnvt cec19d6af6 UI: a reference box's pick ends when the box leaves the screen
Choosing another step or inspector tab hid the picking box but left the pick in place, so a grid
selection made later for another reason landed in the box when its step was shown again, and the
draft was dirtied with a reference nobody chose. A pick now ends in any frame that does not draw
its box.
2026-10-25 06:48:57 -04:00
prxnvt 3d6a09ec75 UI: a reference box stops picking when a macro action starts, and debugging never fills one
A reference box that had focus kept picking from the grid while Check, Run or a debugger action
ran. The selections those make landed in the box and dirtied the draft, and a later Check could
save that draft. The pick now ends when an action starts, and while a debug run is active a
selection on the grid is never taken as a pick.

Also: the parity assessment said an .xlsm's VBA project is dropped on open; it is kept unchanged
on save and never run.
2026-10-25 06:48:57 -04:00
prxnvt b9a8fa304c UI: Change in the macro step editor picks only the commands its list shows
In Change mode the picker hid the Repeat, If and Stop rows but still indexed the full
list, so Enter with an empty search (index 0, nothing highlighted) picked Repeat and
turned the command step into a Repeat step; a search for "if" or "stop" did the same.
The control rows are now dropped from the list before it is indexed, drawn or chosen.
2026-10-25 06:48:57 -04:00
prxnvt c523a4c48a UI: another dialog never replaces the open macro step editor
The step editor lives in the app's one dialog slot, so Format Cells, the Macros
dialog or New Macro opened while it was up took its place. The draft was lost
(New Macro started a fresh one), and a closed-over editor left its grid overlays and
its debug run behind. While the editor is open, requests for other dialogs are now
ignored; the editor closes itself as before.
2026-10-25 06:48:57 -04:00
prxnvt eb7b2b93f4 UI: the macro editor remembers a rename even when the save after it fails
Save renames the stored macro first and then saves the draft. When the save failed
(a draft too large to keep in the workbook, say), saved_as still held the old name,
so every later Save, Check, Run and debug start tried to rename a macro that no
longer existed. saved_as now takes the new name as soon as the rename succeeds.
2026-10-25 06:48:57 -04:00
prxnvt bca9a10f8a UI: saving a new macro never replaces one with the same name
A new draft's name can be edited to that of a macro already in the workbook. Save, and Check and
Run (which save first), then sent macro.save with its default replace: true and the draft took
the other macro's place. A new draft now saves with replace: false, so the clash is reported and
the existing macro stays as it was.
2026-10-25 06:48:57 -04:00
prxnvt 1443ec417c UI: macro step editor with a debug bar and grid overlays
New Macro (Automate › Macros) and Step Into, Edit and Create in the
Macros dialog open a resizable step editor. It works on a draft of the
macro and saves it with macro.save.

The step list shows each step's number, a breakpoint dot in the gutter
(click to set or clear), a category icon, a summary, an error marker
and an enabled checkbox; Repeat bodies and If branches are indented
under their step, and rows drag to reorder within their list. Add Step
opens a command picker (control steps first; commands a macro may
never run are left out) and fills in the command's required
parameters; Duplicate and Delete act on the selected step.

The inspector's Details tab edits the selected step. Reference
parameters are reference boxes that take a typed reference or a
selection on the grid, with a Rel/Abs switch between a token relative
to the example input and an absolute reference, and token suggestions.
The Inputs tab sets each input's shape, header and column types (Use
current selection as example infers them); the Output tab sets the
anchor and the conflict rule.

The debug bar has Check (a dry run that colours the grid: inputs blue,
writes green, writes over data red), Run, Step, Continue, Run to Here
and Stop, which asks whether to keep the changes or undo them. The
debugger receives the editor's breakpoints with breakpointsOnly, so a
dot cleared while paused takes effect at once; the current step is
selected, its parameters are shown as they will run, and its target is
outlined on the grid. While a macro is debugged, the status bar says so
and Show reopens the editor.

List drag and drop lives in a small dnd module. The debugger icons are
drawn in code, and the breakpoint and sheet overlay colours are theme
tokens covered by the contrast test.
2026-10-25 06:48:57 -04:00
prxnvt 5512c1a269 UI: Automate › Macros group, Record Macro and Macros dialogs, recording status, macro shortcuts
The Automate tab gains a Macros group: Macros opens the Macros dialog,
Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut
letter, description, Use Relative References) and turns into Stop
Recording while recording, Use Relative References sets how the next
recording starts, and Run Macro runs the macro run last; its menu lists
the recent macros, then every macro. While recording, the status bar
shows a red dot, "Recording · N steps" and Stop.

The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the
workbook's macros with their shortcuts, then its Automate scripts, and
says when the file carries a VBA project, which is kept and not run.
Run, Delete (which asks first) and Options (shortcut letter and
description) act on the selected row. The dialog keeps the result of
view.macros, so the scripts and the VBA entry are listed as before.

A macro that needs approval asks with Run, and one whose output would
land on data asks with Overwrite, Shift Down and Shift Right; each
button runs the command again with its answer. Ctrl+Shift+letter
(Cmd+Shift on a Mac) runs the macro with that shortcut; the letters
GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV)
now open the Record Macro and Macros dialogs.

Icons for macros, recording and relative references are drawn in code,
the recording dot is a theme token covered by the contrast test, and
large and small ribbon buttons and icon buttons carry accessible names.
2026-10-25 06:48:57 -04:00
prxnvt 7c66aaa2d3 Engine: Record Macro takes a name, shortcut and description
macro.record checks name, shortcut and description when recording
starts and keeps them for the save, so a name that is taken, too long
or holds line breaks, or a Ctrl+Shift letter that GridCraft or another
macro uses, is refused before anything is recorded. With empty
parameters it records under the next MacroN, as before. A name or
description given when stopping still wins, and a shortcut that another
macro took while recording is dropped.

The shortcut rules (macros::parse_shortcut, shortcut_text,
shortcut_conflict, RESERVED_SHORTCUTS and check_new_name) live in the
engine, so the Record Macro dialog, Macro Options and agents agree.
2026-10-25 06:48:57 -04:00
prxnvt bf6a8520b8 Merge the footprint pull request (65634c2b, with #2144 and #2151) into #2145 2026-10-25 06:48:03 -04:00
prxnvt 65634c2bb7 Merge #2144 (b9aa4a6a) into the footprint pull request 2026-10-25 06:48:03 -04:00
prxnvt 0533480c97 Engine: recorded steps carry a number, so a group's rollback finds its own after others
A group remembered the recording's length when it began, which another workbook's rollback (or a
recording started again inside the group) made point at the wrong steps. Each kept step now has
a number, and each recording one of its own; a group remembers both, and its rollback takes back
the steps of its workbook numbered from its mark, or all of them when the recording started
inside the group.

- Test: `rollbacks_take_back_their_own_recorded_steps_in_any_order` (groups on two workbooks
  rolled back in turn, and a recording started again inside a group).
2026-10-25 06:48:03 -04:00
prxnvt b9aa4a6a9c Merge #2151 (0533480c) into #2144 2026-10-25 06:48:03 -04:00
prxnvt ca2be34e8a Engine: a rolled-back group takes what its script runs ran out of the recording too
Taking back the steps a rolled-back group recorded left what a taken-back script or macro run
ran (`Recorder::ran`) under its old index, where a later run could pick it up, and left it out of
the size count. `cut_back` now drops it with its step, moves a kept run's entry to the step's new
index and counts both again.

- Test: `a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording`.
2026-10-25 06:29:23 -04:00
prxnvt 9a22c4b2f2 Merge the footprint pull request (24f0c3df, with #2144 and #2151) into #2145 2026-10-25 06:28:06 -04:00
prxnvt 24f0c3df92 Merge #2144 (1a00ce19) into the footprint pull request 2026-10-25 06:28:06 -04:00
prxnvt 1a00ce19fb Merge #2151 (0a929eaf) into #2144 2026-10-25 06:28:06 -04:00
prxnvt 0a9305f784 Merge #2145 (9a22c4b2) into #2152 2026-10-25 06:28:06 -04:00
prxnvt 0a929eaf68 Engine: a rolled-back group takes back only the steps recorded in its workbook
A rollback took back every step recorded since its group began, so an edit recorded in another
workbook while the group was open was lost from the recording although the edit stayed. It now
takes back only the steps recorded in the group's own workbook.

- Test: the other workbook's edit in `stop_action_runs_only_on_its_own_and_is_not_recorded`.
2026-10-25 06:27:53 -04:00
prxnvt 2e8284c191 Engine: a panic during a dry run puts the journal pause and the touch log back at any depth
The pause of a dry run was cleared only by a panic at the top level. A command that started a
dry run, nested in another, and panicked left journaling paused for the rest of the session (and
the touch log open) once the outer command finished. Each command now keeps the pause count it
started with, and its panic guard puts it back, closing the touch logs, whatever its depth.

- Test: `a_dry_run_cut_short_by_a_panic_leaves_journaling_on`.
2026-10-25 06:26:51 -04:00
prxnvt f0b667aa02 Merge the footprint pull request (361fff98, with #2144 and #2151) into #2145 2026-10-25 06:02:18 -04:00
prxnvt ec58ec5f4b Merge #2145 (f0b667aa) into #2152 2026-10-25 06:02:18 -04:00
prxnvt 361fff982a Engine: a dry run journals nothing, so the journal and Record Actions' start stay as they were
Every nested command a dry run executed was journaled. Past the journal's limits that evicted
the person's own oldest entries, and the restore could only cut back what was left, so one
`macro.check` of a long macro could empty the journal and move a Record Actions recording's
start. While `Saved` holds the session's state, commands are not journaled
(`Session::journal_paused`), and the restore puts the journal and the recording start back
exactly; the count of dropped entries it relied on is gone. The fallible calls of a dry run come
before the pause, and a panic at the top level turns journaling back on.

- Test: `a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were` (a full journal, a
  Record Actions start, and a check of 12,000 nested steps; replaces the test that accepted the
  loss).
2026-10-25 06:02:18 -04:00
prxnvt fe14c0d86e Merge #2151 (0aae1ffe) into #2144 2026-10-25 06:00:19 -04:00
prxnvt 891986bf66 Merge #2144 (fe14c0d8) into the footprint pull request 2026-10-25 06:00:19 -04:00
prxnvt 0aae1ffeea Engine: an xlsx save refuses a macro that skipped the check, as a JSON save does
`with_names` checked only the size of each item, so a macro built in code past the model's
limits (a name with a line break) was written to the file and dropped the next time it opened.
The xlsx save now runs `check` on every macro first.

- Test: the macro past the limits in `a_macro_built_in_code_goes_through_the_size_check_too`.
2026-10-25 06:00:13 -04:00
prxnvt bfcf50aad3 Engine: rolling back an undo group takes back the steps it recorded
Stop Action (or any rollback) put a group's edits back but left the steps recorded while the
group was open in the recording, so playing the recording back made edits the workbook no longer
had. A group remembers where the recording was when it began, and its rollback cuts the
recording back to there.

- Test: `stop_action_runs_only_on_its_own_and_is_not_recorded`, which kept the rolled-back edit
  before.
2026-10-25 06:00:13 -04:00
prxnvt f225cd1026 Engine: Check Macro waits while a macro is being debugged
`macro.check` is a query, so the debugger's lock let it through, but its dry run runs the macro's
steps, which the lock refuses: the check reported the lock as the failure of a step. It now waits
with "Finish or stop the macro first", as other commands that change the workbook do.

- Test: `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
2026-10-25 05:16:37 -04:00
prxnvt 9fe25835d4 Merge #2145 (f225cd10) into #2152 2026-10-25 05:16:37 -04:00
prxnvt f6f4585c93 Engine: a dry run puts the document's revision back
The dry run's rollback counts as a change of the document and moves its revision on, which front
ends key their caches on, so a `macro.check` that changed nothing (and a shift it tried and took
back) looked like a change. `Saved` keeps the revision and puts it back with the rest.

- Test: the revision in `shifting_makes_room_and_is_checked_again`.
2026-10-25 04:14:12 -04:00
prxnvt 91bb5eded8 Merge the footprint pull request (f6f4585c, with #2144) into #2145 2026-10-25 04:14:12 -04:00
prxnvt 5aa10132aa Merge #2145 (91bb5ede, with the footprint pull request and #2144) into #2152 2026-10-25 04:14:12 -04:00
prxnvt 267d5bc503 Merge #2144 (90a46da4) into the footprint pull request
# Conflicts:
#	crates/engine/src/cmd/macros.rs
2026-10-25 04:13:22 -04:00
prxnvt 90a46da496 Engine: macro.run's description lists only what it returns here
`shifted` comes with the footprint in the pull request above; here a shift rule stops the run as
`fail` does, which the description now says.
2026-10-25 04:13:05 -04:00
prxnvt 00b22d5e33 Docs: the macro page keeps what the pull requests below wrote
The merge of #2145 took this branch's macro page whole instead of merging it, which left out the
page's newer text from below: the debugging section as #2145 rewrote it (the lock, Stop Action,
parameters, the time limit for each action), the time limit for each debugger action in the
limits, the check after a shift, and moves that rewrite formula references. The page is now the
merge of both, with this branch's own scope line.
2026-10-25 03:55:52 -04:00
prxnvt fdd26876fb Merge #2145 (227078f5, with the footprint pull request and #2144) into #2152
# Conflicts:
#	docs/macros.md
2026-10-25 03:21:39 -04:00
prxnvt 227078f56a Merge the footprint pull request (dadffdb3, with #2144) into #2145
# Conflicts:
#	docs/macros.md
2026-10-25 03:21:31 -04:00
prxnvt dadffdb33f Engine: macro.check shifts and checks again, as the run does, before it says ok
Under `shiftDown` or `shiftRight` with conflicts, `macro.check` looked only for the refusals made
before a shift, so it said `ok` for a run the second footprint stops (an anchor that moves with
the shifted data). It now shifts the data inside an undo group, takes the footprint again, rolls
both back and puts the session back, and reports what still stands in the way.

- Test: the `Named` case and a shift that makes room in `shifting_makes_room_and_is_checked_again`.
2026-10-25 03:21:15 -04:00
prxnvt e9effe5dbd Merge #2144 (198fe482) into the footprint pull request
# Conflicts:
#	docs/macros.md
2026-10-25 03:20:11 -04:00
prxnvt 198fe482a1 Engine: the run's check of each step finds the inputs on their sheet after a sheet moved
- The check for writes over data compared the inputs' sheet with the sheet's index when the run
  started, while the inputs follow their cells: after a step inserted a sheet in front, a step
  writing into its own input on the moved sheet was stopped as writing over data. It now uses the
  sheet's index now. Test: the input case in
  `inputs_and_the_output_follow_their_cells_when_a_step_moves_them`.
- Docs: the page says `macro.run` checks the macro, its inputs and parameters and the policy
  before it runs, and that a formula whose references a move rewrites counts as changed too.
2026-10-25 03:19:59 -04:00
prxnvt 43134b308e Docs: catalog count 276 / 292 in the parity summary; macro storage names; the macro page's scope
- target-app-parity.md: the summary table and the method say 276 / 292 and 292 unique ids, as
  `cargo xtask parity` measures; Last updated and revision history.
- architecture.md: macros are kept as `_gc_macro_<n>_<k>` (CodeRabbit 4366060044).
- macros.md: the intro says the page covers the engine and its commands, without implying
  later work.
2026-10-25 03:19:35 -04:00
prxnvt 460a72d3bb Engine: a recording keeps what ran and plays back as it was recorded
- A script step that only opened a dialog (a menu-style call that left the workbook unchanged)
  is left out of the script run's steps, by the recorder hook's rule; a script run whose steps
  the recording couldn't keep is left out and listed in `skipped`, not read from the workbook
  as the script stands when recording stops (a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording).
- A script's toggles keep the state they set (`"on": true`), as a top-level Bold does
  (a_script_run_records_the_state_its_toggles_set).
- A recorded macro.run keeps the steps it ran, as a script run does, so saving or deleting the
  macro before the recording stops changes nothing (a_recorded_macro_run_keeps_the_steps_that_ran).
- The example input's shape and column types are inferred when recording starts, not from the
  data the recording changed (the_recorded_input_is_inferred_as_it_was_when_recording_started).
- Recorded text that playback would read as a token stays text: a reference parameter that reads
  as one becomes {"$text": …}, and `{{` becomes `{{{{`
  (recorded_text_that_reads_as_a_token_plays_back_as_text). The resolver (below #2152) gains
  `{"$text": …}`, text passed as it is (text_marked_as_text_is_never_a_token).
- A recording over the macro size limit is cut at the limit and the steps left out are counted
  in `dropped` (a_recording_past_the_macro_size_is_cut_and_counted).
- A macro recorded in a workbook opened from a file is approved for the session, since the
  person recorded it (a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking).
- macro.record refuses parameters it would ignore: name or description when starting, relative
  when stopping, stop: false while recording, others beside cancel: true, and wrong kinds
  (record_refuses_parameters_it_would_ignore).
- Undo while recording keeps the undone step, as in Excel; documented
  (undo_while_recording_keeps_the_undone_step_as_excel_does).
- Docs: macros.md Recording and Tokens; module comments no longer say scripts are read.
2026-10-25 03:19:35 -04:00
prxnvt 8b018d89cd Model: the undo budget counts each macro's whole JSON, parameters included
- Macros::estimated_bytes counted names, descriptions and step counts but not step parameters,
  so each save, rename or delete (which copies the macro list an undo step shares) could keep a
  4 M-character parameter per step unseen by the 128 MB budget. It now counts each macro's JSON
  as it is written, without building it.
- This changes the macro storage layer below #2152, the smallest change that makes the budget
  hold (answers CodeRabbit thread 4366060037).
- Test: re_saving_a_large_macro_keeps_the_undo_history_within_its_budget.
2026-10-25 03:19:03 -04:00
prxnvt bc0d3035fb Engine: kept macros count toward the macro limit and keep their names; saving over a macro needs replace: true; renaming checks the size
- macro.save and a stopped recording count the macros kept in a newer format with the readable
  ones, so a save never holds more than the next open keeps; the message names the kept count
  (kept_macros_count_toward_the_limit_and_keep_their_names, which saves and reopens as xlsx and
  JSON).
- The names of kept macros are taken: macro.save and macro.rename refuse them, unique_name and
  next_name step around them (same test).
- macro.save no longer replaces by default: `replace` defaults to false, the error names the
  existing macro and says to pass `replace: true`, and a `replace` that is not a boolean is
  refused (saving_over_a_macro_needs_replace).
- macro.rename runs the whole macro check, size included, so a longer name can't take a macro
  past what an xlsx file holds (renaming_checks_the_macro_size).
- Docs: macros.md, the management commands and kept macros.
2026-10-25 03:18:26 -04:00
prxnvt f66ebd6fb2 Docs: debugging macros: actions, start options, the lock and the time limit per action
- The start options are those of `macro.run`, refused as it refuses them.
- The time limit applies to each debugger action; the step and parameter limits to the whole run.
- The lock lists what runs between steps, and says the sheet and selection are put back.
- Stop Action ends the run; the debugger is never a step and never recorded.
- The answers' fields, including `changedCells`, `shifted`, `wroteFiles` and `message`.
2026-10-25 03:17:42 -04:00
prxnvt 7dad14e77e Engine: the macro debugger locks its workbook to looking around, ends with Stop Action and checks its parameters
- `macro.debug` refuses to run as a step of a script or macro, and is never recorded (the
  recording buffer and Record Actions' journal both leave it out); a debugger whose undo group
  something else closed locks nothing, and its next action says the run had ended instead of
  reporting a keep or a rollback. Tests: `the_debugger_runs_on_its_own_and_is_never_recorded`,
  `a_debugger_whose_group_was_closed_elsewhere_locks_nothing`.
- Stop Action is no longer refused under the debugger: it rolls the run back, ends the debugger
  and names the macro. Test: `stop_action_stops_a_macro_being_debugged` (was
  `stop_action_waits_while_a_macro_is_being_debugged`).
- Between steps the debugged workbook runs only `macro.debug`, queries and an allow-list of
  commands that look around (selection, sheet changes, Go To, Find, Copy, zoom, Switch Window,
  Close, Stop Action); everything else is refused, saving and view modes included. The run's
  active sheet and selection are put back before its next step. Test:
  `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
- Only the top-level `macro.debug` call rolls the run back after a panic; a panic nested in a step
  is that step's error. Test: `a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on`.
- `keep` and `breakpointsOnly` must be true or false, breakpoints must be step paths of the macro
  (at most 10,000), and the action is checked before breakpoints change. Test:
  `debugger_parameters_are_checked_before_anything_changes`.
- Answers: `executed` counts the run's own steps (not the footprint's), `shifted` and `wroteFiles`
  are reported, a rollback's message names the files that stay, `changed` lists deleted and
  renamed sheets and at most 500 entries with `changedCells`, and a Repeat over whole rows or
  columns shows the part in use. Tests: `the_debuggers_answers_count_its_own_steps_and_say_what_stays`,
  `the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list`,
  `the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them`.
2026-10-25 03:17:42 -04:00
prxnvt d309c85c31 Engine: Stop Action waits while a macro is being debugged
`automate.stopAction` rolls back the undo groups open on the workbook. While a macro was being
debugged it rolled back the debugger's own group and left the debugger set, pointing at a group
that was gone. It is now refused under the debugger's lock with "Finish or stop the macro first",
as edits are; `macro.debug {action: "stop"}` ends the run.

- Test: `stop_action_waits_while_a_macro_is_being_debugged`.
2026-10-25 02:52:43 -04:00
prxnvt 60e0039524 Merge #2145 (ce2ab9a4, with the footprint pull request and #2144) into #2152
# Conflicts:
#	docs/macros.md
2026-10-25 02:50:32 -04:00
prxnvt ce2ab9a473 Merge the footprint pull request (c715c36c, with #2144) into #2145
# Conflicts:
#	docs/macros.md
2026-10-25 02:50:17 -04:00
prxnvt c715c36c07 Merge #2144 (2cf5b808) into the footprint pull request
# Conflicts:
#	docs/macros.md
2026-10-25 02:49:50 -04:00
prxnvt 2cf5b808b3 Docs: the macro page's conflict rules wrap at the page's width 2026-10-25 02:49:42 -04:00
prxnvt 3708c80168 Engine: macro.check says whether the run would go ahead; the question names what the answer covers
- `ok` in `macro.check` meant only that the dry run hit no error. It now says whether
  `macro.run` with the same parameters would go ahead without a question, and `errors` says
  why not: a failing step, a Stop step that rolls back, data written over under `ask` or `fail`,
  a shift that can't make room (the refusals `make_room` makes, now in `shift_refusal`), or a
  run past the step or parameter limit once the dry runs before it count. `macro.check` takes
  `onConflict` and `overwrite` as `macro.run` does, reports the rule it used, and refuses the
  parameters `macro.run` refuses.
- After a partial footprint, the question under `ask` says where the check stopped and that
  overwriting also lets the later steps write over data unchecked.
- The dry runs and the run share the parameter budget as they share the step budget.
- A dry run puts back the Go To list (`edit.goTo` with `record: true`).
- The footprint groups the touch log by sheet once, instead of filtering a million entries once
  per sheet.
- Docs: what `ok` means; RAND's sequence moves on after a check, as after any recalculation.
- Tests: `the_check_says_whether_the_run_would_go_ahead`,
  `the_question_after_a_partial_footprint_says_what_the_answer_covers`,
  `a_dry_run_puts_back_the_go_to_list`, the shared parameter budget, and `ok` under `ask` in
  `conflicts_ask_fail_or_overwrite`.
2026-10-25 02:49:35 -04:00
prxnvt 567ed0abc9 Merge #2144 (65cbcdd6) into the footprint pull request
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/macros/run.rs
#	crates/engine/src/tests/macros.rs
#	docs/macros.md
2026-10-25 02:45:42 -04:00
prxnvt 65cbcdd6bd Engine: a macro run names the files it kept, refuses parameters it would ignore, and bounds its work
- A run that rolls back after writing a file no longer says "Nothing was changed.": the message
  says the workbook was put back and names the step whose file stays (`StepError::wrote_files`,
  also after a Stop with rollback).
- `macro.run` refuses an input the macro doesn't have (a misspelt input bound the selection),
  an input that isn't reference text, and `onConflict`, `overwrite`, `approve` or `timeLimit` of
  the wrong kind, which it used to ignore; `null` still counts as not given.
- A step no longer copies itself, and Repeat and If no longer copy their bodies, on every pass:
  a body is copied once per run. A run passes its commands at most 256 MB of parameters, all
  steps together, which bounds a run's work on the web build, where there is no clock.
- Every step except an approved file step runs inside the scripts' file sandbox, so a command
  that touches a file under a parameter other than `path` can't do so from a macro.
- Docs: moving cells that hold data counts as writing over them.
- Tests: `run_parameters_that_would_be_ignored_are_refused`,
  `a_run_passes_a_bounded_amount_of_parameters`, and the rollback message in
  `each_step_is_checked_for_writes_over_data`.
2026-10-25 02:44:15 -04:00
prxnvt ad79b071ce Engine: a macro's inputs, output and Repeat ranges follow their cells when a step moves them
Inputs, the output and a Repeat's range were bound once, by sheet index and coordinates. A step
that inserted a sheet in front of an input's sheet sent every later token to the new sheet, and
rows inserted above an input left the tokens on the cells that moved into its place; the run
wrote into the wrong cells and reported success.

- Commands record the structural edits they make as they rewrite references (`Ctx::edits`; while
  a macro step runs, `DocState::edits` collects them).
- After each step the machine matches the sheets as the run's guard does and moves the inputs,
  the output and the ranges of the Repeats in progress by those edits, with the formula engine's
  own adjustment.
- A step that deletes an input's or the output's cells, or their sheet, or changes the size of a
  range a Repeat is going over, stops the run, which rolls back.
- Test: `inputs_and_the_output_follow_their_cells_when_a_step_moves_them` (a sheet inserted in
  front, rows inserted above, an input deleted, a Repeat's range grown).
2026-10-25 02:40:21 -04:00
prxnvt d053b1f548 Engine: a run is checked step by step even when its footprint was complete
A footprint is not proof of what the run will do. RAND gives the run other values than the dry
run before it (the recalculation's random sequence goes on), and NOW moves on, so an If can take
another branch in the run and write over data the dry run never reached; under `fail` the run
went ahead and wrote over it.

- Unless the rule is `overwrite`, every run checks each step as it goes, as a run after a
  partial footprint already did; a write the footprint didn't see stops the run, which asks under
  `ask` and fails otherwise, and rolls back.
- Test: `a_run_that_takes_another_path_than_its_check_is_still_checked` (an If on `=RAND()` over
  40 runs under `fail`; before, one of them wrote over B1).
2026-10-25 02:35:02 -04:00
prxnvt 0719458114 Engine: macro.check and the footprint: where a macro writes, worked out before it runs
Split from the pull request below at the review's request; the code is the runner's footprint
as reviewed there, unchanged.

- `macro.run` first runs the macro in dry-run mode on the real workbook (unless the rule is
  `overwrite`) and puts everything back: the workbook, its selection and history through the
  dry run's undo group, and the session state a dry run may touch (`Saved`: the active
  workbook, open documents, the saved mark, selections, unlocked ranges, trust, clipboard,
  preferences, mode, journal and recording, queued requests, format painter, find, ruler,
  hidden windows, drawing tool and the comment panes).
- Cells it would write outside its inputs that hold data, and sheets and objects it would
  delete, are conflicts: `ask` asks before anything runs, `fail` fails, and `shiftDown` and
  `shiftRight` insert cells over the block of every written cell and check again. A shift is
  refused when it would move an input, when the macro deletes sheets or objects, or when the
  touch log is full.
- A dry run stops before a file step and before Solver; the footprint is then partial, and the
  run checks each step as it goes.
- The dry runs and the run share one clock, one time limit and one step budget.
- `macro.check` reports the inputs, the output anchor, approval needed and the footprint.
- Tests: the footprint tests that moved with this code.
2026-10-25 02:33:26 -04:00
prxnvt 5860857937 Engine: a macro run checks each step for writes over data; the check before the run moves to its own pull request
The review asked for the runner to be split. This pull request keeps the policy, the input
checks, the step machine and `macro.run`; working out before a run where a macro writes (the
footprint dry run, shifting data to make room, `macro.check`) moves to the pull request above it.

- Unless the conflict rule is `overwrite`, every run now checks each step as it runs, which the
  run did before only after a partial footprint: a step that writes over cells outside the inputs
  that held data when the run started, or deletes sheets or objects that were there, stops the
  run, which asks (`ask`) or fails, and the run rolls back.
- `shiftDown` and `shiftRight` need the footprint, so here they stop the run as `fail` does.
- Moved: `Footprint`, `Saved`, `footprint`, `diff`, `make_room`, `macro.check`, the dry-run
  branches of the machine, `policy::previewable`, `DocState::touched` and the touch log,
  `Session::journal_dropped` and `truncate_journal`, and the tests of those.
- Test: `each_step_is_checked_for_writes_over_data` (the review's counterexample: a permitted
  file step or Solver, then a write over B1, under `fail` and `ask`).
2026-10-25 02:32:37 -04:00
prxnvt b40fa4e065 Merge #2145 (2fb31f11, with main at 17b9f6ff) into #2152
Main refuses window.* steps in scripts, so the recorder test's script step that a macro may not
run is app.commands.
2026-10-25 02:29:03 -04:00
prxnvt 2fb31f11f2 Merge #2144 (8ea4e279, with main at 17b9f6ff) into #2145 2026-10-25 02:26:03 -04:00
prxnvt 8ea4e27918 Merge #2151 (63a29800, with main at 17b9f6ff) into #2144
Main adds UiRequest::InsertName (Paste Name, Use in Formula), which puts a name in the cell
editor; a macro step drops it, as it drops a request to start editing a cell.
2026-10-25 02:26:02 -04:00
prxnvt 63a29800a3 Merge main at 17b9f6ff into #2151 2026-10-25 02:19:57 -04:00
prxnvt b995c7c9a0 Engine: a recorded script run keeps the steps that ran
The recorder expanded an automate.runScript step from the workbook as it
stood when the recording stopped, so a script edited or deleted during the
recording changed the saved macro, or dropped the step. The run now leaves
the steps it ran with the recorder (Recorder::ran, by step index, within
the byte budget), and the conversion reads those, falling back to the
workbook only when they could not be kept.
2026-10-25 02:18:58 -04:00
prxnvt 1e34a76428 Merge #2145 (c225caf3, with #2144, #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2152
# Conflicts:
#	crates/engine/src/cmd/extra.rs
#	docs/architecture.md
2026-10-25 02:18:55 -04:00
prxnvt c225caf30a Merge #2144 (9331b05f, with #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2145 2026-10-25 02:18:21 -04:00
prxnvt 9331b05f62 Merge #2151 (9fdf03cf, with #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2144
# Conflicts:
#	crates/engine/src/lib.rs
#	docs/macros.md
2026-10-25 02:17:32 -04:00
prxnvt 9fdf03cfb5 Merge #465 (3f8e6c28, with main at 6ead1fb5, where #462 is merged) into #2151 2026-10-25 02:16:47 -04:00
prxnvt 3f8e6c2884 Engine: a JSON save refuses a macro that skipped the check, as an xlsx save does
Every path that adds a macro runs macros::check, and an xlsx save of a
macro that skipped it fails (the stash refuses the oversized item). A
JSON save wrote such a macro instead, and the next open dropped it with
a warning. json_bytes now checks each macro first, so the save fails in
either format with the check's message and the macro stays in the open
workbook. Tests that build hostile JSON files write them directly.
2026-10-25 00:27:42 -04:00
prxnvt e88760b480 Engine: an item too large to read takes no place in a stash listing
stash::items capped the items at MAX_ITEMS before reading them, so one
over the size limit took a place (a readable item past the cap was then
left out with fewer than MAX_ITEMS listed) and one past the cap was
counted in `more` though it could never be listed. Items are now read
first: the first MAX_ITEMS readable ones are listed and only readable
ones past them are counted.
2026-10-25 00:10:14 -04:00
prxnvt e285194643 Docs: a kept macro is saved back unchanged, not byte for byte 2026-10-24 23:45:04 -04:00
prxnvt 4c11929fe2 Tests: a visible legacy name of the macro storage shape survives a save
The stash reads a legacy _sc_<key> name as data whether or not it is
hidden, so writing key macro_0 removed a person's visible _sc_macro_0
name with no word. The index allocator steps around that key as well;
this test fails without it.
2026-10-24 23:45:04 -04:00
prxnvt da439b71ed Tests: a visible clash, a newer macro and a broken input together, through both formats
One file with a visible name of the storage shape, a macro in a newer
format and one whose input declaration breaks the rules, saved and
reopened xlsx then JSON and JSON then xlsx: the visible name stays, the
newer macro comes back whole and numbered around the name, the broken
one is left out once, and the readable one survives.
2026-10-24 23:45:04 -04:00
prxnvt 3905a4318e Engine: a macro in a newer format is kept unread and saved back unchanged
A macro whose format version is above this build's was dropped on open,
so the next save removed it from the file, and the warning said only
that it couldn't be opened. It is now kept as the file held it
(Workbook::macros_unread, shared between snapshots), never read, listed
or run, and written back unchanged by JSON and xlsx saves, numbered
after the readable macros and around visible names of the storage
shape. Kept macros count toward the 1,000-macro limit after the
readable ones; one too large for an xlsx item is left out. The warning
says it is kept and saved back; damaged and over-limit macros keep
their own warning.
2026-10-24 23:45:04 -04:00
prxnvt 471a40bd72 Model: every field a macro file sets has a limit, and an input declaration is checked as a whole
check_limits capped the name, description, notes, step count, depth and
input names, and left the shortcut, permissions, output anchor, default
range, column types, command ids, Repeat ranges, If cells and values and
Stop messages bounded only by the item size; the input declaration was
stored unchecked (rows [5, 2], a cell input with cols [2, 5]).

Now each of them has a limit (docs/macros.md lists them), one-line
fields refuse control characters, and a command id is letters, digits,
. and _ (every registered id passes, tested). An If value may be null
(Empty and NotEmpty ignore it); Repeat.max stays the runner's to bound.
An input's least rows and columns can't pass its most, a cell input is
one row and one column with no header and at most one column type, and
no input lists more column types than columns. check_limits takes every
struct apart field by field, so a new field doesn't compile there until
its limit is decided. A macro from a file that breaks a rule is left
out with the usual warning, never changed to fit.
2026-10-24 23:39:14 -04:00
prxnvt ac36eee23d Engine: a JSON workbook with no sheets keeps its macros
The JSON reader replaces a workbook with an empty sheet list by a new
one before macros::load reads the file's macro entries, so they were
lost with no warning. The entries are carried across the reset. The
reset itself, which also starts the names and styles afresh, is
unchanged.
2026-10-24 23:35:32 -04:00
prxnvt 3c722f4d86 Engine: a visible name of the macro storage shape no longer blocks an xlsx save
with_names numbered macros 0..N, and stash::set_many refuses to write a
hidden name beside a visible one of the same name, so a visible
_gc_macro_0_0 (from a file, or defined after the file opened, when the
stored names are no longer in the workbook) made every xlsx save of a
workbook with macros fail. The index is GridCraft's choice: macros now
take the free indices from 0 up, skipping every key a visible
workbook-scope name of the storage shape uses (compared as the stash
reads names), in their order. No arithmetic depends on a number from
the file.
2026-10-24 23:35:03 -04:00
Harrison Booth 9ed1001035 Merge remote-tracking branch 'origin/main' into gc-xls-cell-styles
# Conflicts:
#	ROADMAP.md
2026-10-24 15:32:17 -05:00
Harrison Booth bf85ba482c Align .xls estimate in gaps with file-format parity 2026-10-24 15:10:40 -05:00
Daniel b36a3a9b8b Merge upstream main and preserve GNOME Wayland controls
Integrate GridCraft's latest Cargo/package changes and keep the font-free CSD feature, NOTICE and packaging.
2026-10-24 10:23:59 +02:00
prxnvt 54bd4eca9d Engine: a run that edited the workbook before opening a dialog is recorded
The recorder left out every call that opened a dialog, so a script with an
edit before a menu-style step lost that edit from the recording. A call is
left out only when it opened a dialog and changed nothing (the dialog's
confirming call is the step); one that changed the workbook is kept.
2026-10-24 02:42:45 -04:00
prxnvt 6cd4cebfb7 Engine: Stop Action runs on its own and is not recorded
Run from inside another command, automate.stopAction rolled back that
command's own group, pulling the workbook from under the rest of its run; and
the recording buffer kept it, so a recording could replay the stop inside the
run it was part of. Nested, the command is refused and the group stays; the
recorder leaves it out (record::NOT_RECORDED).
2026-10-24 02:18:07 -04:00
prxnvt f040b4ecaa Engine: stash listings say how many items were past the cap
stash::items and stash::keys listed the first 10,000 items and let the rest
vanish without a word. They return a Listing { found, more } now, so a caller
can say what it did not list.
2026-10-24 02:18:07 -04:00
prxnvt fddf2d9a31 Merge main at 6ead1fb5 (#462 merged) into #465
# Conflicts:
#	crates/engine/src/cmd/stash.rs
2026-10-24 02:10:51 -04:00
prxnvt 95c7e70ce6 Merge #462 (3b25aa0d, with main at b8771d51) into #465
# Conflicts:
#	crates/engine/src/cmd/stash.rs
2026-10-24 01:53:52 -04:00
prxnvt 3b25aa0d16 Docs: the panic guard's note says what happens to a group the command ended
It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
2026-10-24 01:41:03 -04:00
prxnvt 4a294aefa9 Engine: the name commands use the stash's own rule for what holds its data
The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.
2026-10-24 01:41:03 -04:00
prxnvt b09a5502e3 UI: the toolbar's Undo and Redo follow the commands' own availability
The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.
2026-10-24 01:30:15 -04:00
prxnvt b44a5139ab Engine: the name commands refuse only names that hold GridCraft's data
The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.
2026-10-24 01:19:07 -04:00
prxnvt f2a7252f93 Merge main into #462 2026-10-24 01:07:18 -04:00
prxnvt 9bbba198c6 Engine: a document's maker says where its content came from
`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.

`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.
2026-10-24 01:04:51 -04:00
prxnvt 37bf09e94a Engine: queries take no copy of the undo history
`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.

The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.
2026-10-24 01:04:51 -04:00
prxnvt 4668fc30ae Engine: the name commands leave GridCraft's data names alone
`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.

Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.
2026-10-24 01:04:51 -04:00
prxnvt b90710794a Engine: list stash items in one pass over the names
Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.

`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.
2026-10-24 00:55:07 -04:00
prxnvt 0fa6e78863 Engine: stash items keep characters XML can't carry
The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.

`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.
2026-10-24 00:53:38 -04:00
prxnvt 2e72407fc6 Engine: the recorder skips a call that only opened a dialog
A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.

`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.
2026-10-24 00:51:37 -04:00
prxnvt 2b33fcf881 Engine: a failed command rolls back the undo groups it opened
A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.

`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.

`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.
2026-10-24 00:49:43 -04:00
prxnvt 359cce4bc3 Tests and docs: merge fallout from main and the lower branches
automate.allScripts reports trusted since #2178, and scripts reopened from
a file are not trusted yet, so the side-by-side test expects the flag and
compares names and steps after the round trip. cargo xtask parity gives
276 / 292 now (upstream added a command), so the ROADMAP and ui-parity
summaries say so, and this branch's revision rows (275 / 291 to 276 / 292)
sit at the top of their tables with the date they land.
2026-10-24 00:36:36 -04:00
prxnvt ee8e5d35c0 Merge #2145 (bac5b9c6, with #2144 at 2215934c, #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2152
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/macros.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-24 00:28:47 -04:00
prxnvt bac5b9c66e Merge #2144 (2215934c, with #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2145
# Conflicts:
#	crates/engine/src/tests/macros.rs
#	docs/macros.md
2026-10-24 00:10:28 -04:00
prxnvt bb3db53489 Engine: the macro recorder keeps a step's own sheet, stays bounded, and can be cancelled
Six fixes from a review of the recorder, each with a regression test (four
fail against the old recorder; the other two bound work that grew with the
input):

- A step that names another sheet with its sheet parameter (cell.set and
  about fifty other commands) was tokenized as if on the input's sheet, so a
  recorded cell.set {sheet: Two, cell: C4} ran on Two at the input's
  relative place. Such a step keeps its references, like a sheet-qualified
  one.
- Inlining recorded runs of other macros and scripts stops at the macro's
  step limit and counts the rest in dropped, instead of expanding every run
  first (a thousand runs of a long macro made millions of steps).
- macro.record {cancel: true} ends a recording without saving it: the way
  out of a recording whose macro is too large for the workbook, which could
  neither be saved nor ended.
- Formula tokenization reads each reference in the formula and matches it,
  instead of listing every column and row of the example (an example as
  large as a sheet made a million candidates per formula).
- A selection of several areas keeps its active cell as it is; a token for
  the active cell alone put it outside the areas on playback.
- Steps recorded in another workbook are counted in dropped.
2026-10-23 23:56:32 -04:00
prxnvt 2215934c9d Engine: a shift is refused when the footprint cannot follow every write
The touch log keeps at most a million writes; past that, a write that kept a
cell's value is missing from the footprint, so a shift derived from the
block of written cells could split a row the macro writes. The footprint
now says when the log filled up (logFull in macro.check), and a shift is
refused then, as it is when the macro deletes sheets or objects. Regression
test with a million-cell fill in a dry run.
2026-10-23 23:55:39 -04:00
prxnvt 879b96f858 Docs: the footprint dry run is skipped when the conflict rule is overwrite 2026-10-23 23:30:35 -04:00
prxnvt a626705c95 Engine: a dry run puts the journal back even after the journal trimmed itself
Every nested command a dry run executes is journaled, so a long macro pushes
the journal past its limit and the journal drops its oldest entries. The
restore cut the journal back to its old length, which then kept entries from
the dry run and pointed Record Actions at the wrong place. The session now
counts the entries its journal has dropped (journal_dropped) and truncates
with its size bookkeeping (truncate_journal); the restore keeps the old
length less what was dropped meanwhile and moves the recording's start back
by the same count. Regression test with 12,000 nested steps.
2026-10-23 23:30:35 -04:00
prxnvt fb4acb04a2 Engine: closing the debugged workbook ends the debugger
file.close is not an edit, so the debugger's lock let it through, and the
session kept the debugger with a token for a group that went with the
document: the next workbook could not start a debugger, and the state
reported that no document was open. close_document (also the path that
replaces an untouched blank workbook on file.open) now ends a debugger whose
workbook it closes. Regression test.
2026-10-23 23:30:14 -04:00
prxnvt 74ff8ac625 Engine: macro runs refuse a shift that moves an input, stay clean when nothing changed, take the answer however ask was given, and share one step budget
Four fixes from a review of the runner, each with a regression test that
fails without it:

- A shift inserts cells over the block of every written cell, which moves
  that block's columns (or rows) from there on. When a bound input lay in
  the way, its data moved and sheared while the run kept the old coordinates
  and reported success. The shift is now refused, naming the input and its
  place. The output is not counted: it is where the macro writes, and the
  second footprint catches data a shift moved into it.
- A dry run or a refused run left a clean workbook dirty: view.zoom,
  sheet.activate and the view modes move the saved mark to the current
  workbook on a clean document, and the rollback put the workbook back but
  not the mark. Saved keeps and restores it.
- overwrite: true answers the question however ask was given; with an
  explicit onConflict: ask the answer was ignored and the question came back
  forever.
- The dry runs and the run share one step budget as they share one clock;
  each phase counted its own 100,000 steps before. ran reports the run's
  own steps.
2026-10-23 23:13:18 -04:00
prxnvt 461395d0a0 Engine: col and cols keep the header state, so a later data takes the header off
Input.col("date").data resolved to the whole column, header included, because
every method cleared the header state. col and cols keep it; the other
methods still clear it, and only data keeps the header row for col("…").
The token test asks for D3:D11, cols(2..3).data and col(2).data.col(1).
2026-10-23 23:09:02 -04:00
prxnvt 551eda7e8e Engine: the xlsx copy gets an empty macro list, not a copy of the shared one cleared
out.macros shares its list with the workbook being saved, so clear() copied
every macro first.
2026-10-23 23:08:49 -04:00
prxnvt 43b0e39e8e Engine: set_many refuses one key given twice
Two items under one key, in any case, would leave two hidden names of one
name, of which an xlsx file keeps one, and get would mix their chunks. A
caller's mistake, refused before anything changes. Regression test.
2026-10-23 23:08:49 -04:00
prxnvt bbf36000d4 Engine: the recorder counts a dropped step without serializing it
Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.
2026-10-23 23:08:36 -04:00
prxnvt 0df540f166 Merge #2151 (44a2f4e2, with #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2144
# Conflicts:
#	docs/macros.md
2026-10-23 22:36:10 -04:00
prxnvt 44a2f4e2f1 Merge #465 (673af7ec, with #462 at 712bb766 and main at 530a8cc8) into #2151
# Conflicts:
#	docs/macros.md
2026-10-23 22:08:05 -04:00
prxnvt 00b63468e6 Engine: macro tokens resolve only where they mean a reference, and refuse instead of guessing
Six fixes from a review of the resolver, each with a regression test that
fails without it:

- A bare Row or Column under a general reference key (to, from, at, area,
  source, target, destination, location) stays text: Change Spelling's
  replacement word and a pivot field's area were being rewritten into the
  current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still
  resolve there, and the bare word still resolves under cell and range keys.
- {{{{ in a string is a literal {{, so text that needs the braces can be
  written; an unknown token is still an error.
- Input.data on a range that is only a header row, and Name: of a table
  with no data rows, are refused instead of reading the header.
- below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead
  of becoming 1.
- A defined name on a sheet whose name holds a dollar sign resolves: the
  dollar anchors are stripped from the reference part only.
- quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which
  quotes a sheet named like a cell reference, an R1C1 token or a boolean and
  leaves a period unquoted.

docs/macros.md says which keys take a bare token and how to write a literal
brace.
2026-10-23 22:02:45 -04:00
prxnvt 673af7ecdc Merge #462 (712bb766) into #465 2026-10-23 22:00:39 -04:00
prxnvt 4672b6da51 Model: an input name that reads as a cell reference is refused
A macro could declare an input called Q1, AB12 or Sum1, and a step written
with plain A1 text, as a recorded one is, would then resolve to the input's
range instead of the cell. check_limits refuses such names; the docs say so.
Regression test.
2026-10-23 21:48:53 -04:00
prxnvt 712bb766ec Engine: a grouped action that panics is rolled back before the panic goes on
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
2026-10-23 21:46:25 -04:00
prxnvt 8219c18b92 Engine: macro storage shares the list, saves in one pass, keeps unknown names, checks once
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
  workbook copy made for a cell edit or an undo snapshot holds the same list,
  and the undo budget charges a list once a snapshot owns its own copy.
  Before, every mutating command copied every step of every macro, and a
  hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
  pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
  kept on open and on save, as a visible one is, instead of being dropped
  without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
  file, built in code): the model's limits and the xlsx size limit.

Regression tests for each.
2026-10-23 21:45:54 -04:00
prxnvt 259b6e67e8 Model: Repeat and If nest four deep, as the limit says
The depth check refused any step list at the fifth level, so a fourth Repeat
or If could not even hold an empty body: three levels nested, not the four
MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If
steps themselves, in the model's limits and in the parser's pass over the
JSON. The test asks for four and refuses a fifth.
2026-10-23 21:45:54 -04:00
prxnvt 7ee9d77483 Docs: list every paste that carries a file's trust
The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.
2026-10-23 21:36:09 -04:00
prxnvt 3ca1252571 Engine: a group a panicking command ended keeps its step
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
2026-10-23 21:36:09 -04:00
prxnvt 378ce3a5a0 Docs: the macro page describes this version
The inputs paragraph described how a run binds inputs and the message it
shows, which the runner adds in a later pull request. It now says what the
declaration is for and that this version stores and checks it.
2026-10-23 21:28:05 -04:00
prxnvt cef58da39e Merge #462 (d7c3c183, with main at 530a8cc8) into #465 2026-10-23 21:25:35 -04:00
prxnvt d7c3c18345 Engine: the panic guard restores entries a nested Undo removed
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
2026-10-23 20:47:40 -04:00
prxnvt 8440427c53 Tests: a linked paste of column widths or notes copies its metadata and no cell
The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.
2026-10-23 20:15:05 -04:00
prxnvt d1e51a4af4 Engine: a later stash chunk on its own is not a listed item
stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.
2026-10-23 20:08:40 -04:00
prxnvt 456cf73886 Engine: a headless call that fails for want of a file is not recorded
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
2026-10-23 20:08:40 -04:00
prxnvt a3b5f8e45c Engine: a command that panics leaves the undo history as it found it
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
2026-10-23 20:08:40 -04:00
prxnvt 6b7c48f119 Engine: a stash chunk number with leading zeros names someone else's name
`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.
2026-10-23 20:01:21 -04:00
prxnvt 66e69cb946 Engine: a linked paste carries the file's trust whatever it pastes
Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.
2026-10-23 20:01:21 -04:00
Harrison Booth ad2e3ac9de Check duplicate .xls style names in linear time 2026-10-23 16:37:19 -05:00
Harrison Booth 2c052d03d5 Read cell formatting from .xls files
.xls (Excel 97-2003) import used only each cell's number format. It now
also reads the BIFF8 formatting records (Font, XF, Palette and Style),
so opened sheets keep their fonts, solid and pattern fills, borders
(including diagonals), alignment (rotation, indent, wrap, shrink),
locked and hidden protection, the workbook's custom colour palette, its
Normal font as the default, and its user-defined cell styles.

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

Also merges origin/main, keeping its dialog-sheet, WINDOW2 and XF
number-format handling; the XF arm now goes through the new styles
module, which covers the number format too.
2026-10-23 16:32:15 -05:00
prxnvt 7e383f460a Docs: undo groups at a command's boundaries, and the bound on steps made inside a command 2026-10-23 16:36:52 -04:00
prxnvt 080b6d993b Engine: a grouped action that closes its document succeeds
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
2026-10-23 16:36:34 -04:00
prxnvt 6ab8d97118 Engine: the undo steps made inside a command are bounded while it runs
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.

Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
2026-10-23 16:35:45 -04:00
prxnvt 114088d589 Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.

A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.

The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
2026-10-23 16:33:42 -04:00
prxnvt 5ca886507c Engine: writing a stash item checks for a clashing visible name in one pass over the names
The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.
2026-10-23 16:21:48 -04:00
prxnvt 524826687c Docs: the architecture revision row carries the date the change lands; document trust beside script trust
The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).
2026-10-23 16:17:57 -04:00
prxnvt a8e8bf851e Engine: the recording buffer is bounded in bytes and keeps the start of a recording
The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.
2026-10-23 16:17:57 -04:00
prxnvt 18ec913c4e Engine: a command keeps its group's name only for a step that is exactly the group's
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.

The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
2026-10-23 16:17:57 -04:00
prxnvt 602d4450a2 Merge main into #462 2026-10-23 16:10:43 -04:00
prxnvt 67fa9f7b4e Merge main into #2152
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-22 02:39:09 -04:00
prxnvt bca1483e2e Merge main into #2145 2026-10-22 02:37:04 -04:00
prxnvt 38ad2dba83 Merge main into #2144
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-22 02:37:04 -04:00
prxnvt 3eed81318f Merge main into #2151 2026-10-22 02:36:09 -04:00
prxnvt faff24f3aa Merge main into #465
# Conflicts:
#	crates/engine/src/io.rs
2026-10-22 02:36:09 -04:00
prxnvt 66f7993ba5 Merge main into #462
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	docs/architecture.md
2026-10-22 02:30:25 -04:00
prxnvt bbf1bc441b Engine: stopping a recording waits while a macro in its workbook is being debugged
Stopping a recording saved the macro with a nested macro.save while a debugged run's undo group could be open on the same workbook. Stopping the run without keeping its changes then deleted the macro that macro.record had reported as saved. Stopping now fails with "Finish or stop the macro first" under macro.record before anything changes, and the recording goes on, as when a save fails for another reason, so stopping again after the run ends saves it.
2026-10-22 02:26:33 -04:00
prxnvt ea691f703c Engine: a stopped recording reports the steps cut at the macro step limit in dropped
A recording keeps up to 100,000 commands, and inlined macro and script runs can add more, but a macro holds at most 10,000 steps. Stopping truncated the converted steps to that limit without saying so: dropped counted only the recorder's own overflow, so a 10,005-step recording saved a 10,000-step macro and reported nothing missing. The steps cut at the macro limit are now added to dropped.
2026-10-22 02:26:33 -04:00
prxnvt 3f5f6ea4f1 Engine: a name given when a recording stops never replaces an existing macro
Stopping a recording with a name that a macro already has (in any case) saved over that macro's
steps, inputs and description, with only Undo to get them back. A taken name now gets a suffix
("Tidy (2)"), and the save passes replace: false as a backstop.
2026-10-22 02:26:33 -04:00
prxnvt 20eb282e75 Docs: catalog count 275 / 291 in the roadmap and parity summaries
The catalog went from 274 / 290 to 275 / 291 with View › Macros › Record Macro, and
parity-checklist.md says so, but the ROADMAP.md headline, ui-parity.md and target-app-parity.md
still said 274 / 290 (and 290 catalog ids). They now match, with revision rows.
2026-10-22 02:26:33 -04:00
prxnvt 6a004bf610 Engine: stopping Record Actions waits while a macro in its workbook is being debugged
Stopping Record Actions saves a script into the workbook, which the debugger lock refuses while a
macro there is paused. The refusal came after Record Actions had taken its recording, so the
recording was lost. Stopping is now refused before anything changes, and stopping again after
the run saves the script.
2026-10-22 02:26:32 -04:00
prxnvt c0079aabe9 Docs: a failed macro run rolls back workbook changes; a file it already wrote stays
The intro said a macro run rolls everything back on an error, but a step that exports or prints to a path writes the file before a later step can fail, and the Running section says that file stays. The intro now limits the guarantee to workbook changes.
2026-10-22 02:26:32 -04:00
prxnvt 045cf428f5 Engine: a column header made by a formula matches its token while formulas are shown
`col("Amount")` read the header through the cell's display text, which is the formula while
Show Formulas is on, so a header such as ="Amo"&"unt" matched nothing. The header is now the
cell's value as formatted, whatever the sheet shows.
2026-10-22 02:26:32 -04:00
prxnvt 6312c7cb40 Engine: only hidden names are read as macro storage
A visible workbook-scope name such as `_gc_macro_0_0` matched the storage key and was read as a
macro and removed when the workbook opened. Macro storage is written hidden, so only hidden names
are read back; a person's own name of that shape stays a name.
2026-10-22 02:26:32 -04:00
prxnvt 8797b7183c Engine: stopping a macro recording saves it in the workbook where recording started
Stopping a recording converted and saved it against the active workbook. For example, starting a
recording in Book1 and switching to Book2 before stopping saved the macro into Book2, inferred the
input's header and column types from Book2's cells and looked up inlined macros and scripts
there. The recording is now converted and saved against the workbook where it started, and the
active workbook stays active. If that workbook was closed, stopping returns an error and ends the
recording.
2026-10-22 02:26:32 -04:00
prxnvt f553619b33 Engine: the macro debugger lock also refuses edits that another command runs
Session::execute checked the debugger lock only for top-level undoable commands. A command that is not undoable could run edits as nested commands and skip it: stopping Record Actions while a macro in the same workbook was being debugged saved the script inside the run's undo group, and stopping the run without keeping its changes deleted the script again. The lock now applies at every depth, and only the debugged run's own steps go ahead.
2026-10-22 02:26:32 -04:00
prxnvt f1a46a2b53 Docs: the macro policy summary lists the file commands that do run
The policy summary said file commands never run in a macro, but File >
Properties runs, and exports and printing run: without a path they only
produce bytes, and with one they need the permission and approval. The
summary now names what is denied and what runs, and the paragraph is
wrapped to the file's line length.
2026-10-22 02:26:32 -04:00
prxnvt 0ea5d3ee33 Engine: a Name: token reaches a defined name that holds periods
`Name:` read a name only up to the first period, so `Name:Sales.Q1` looked up `Sales` and
treated `Q1` as a method, and a workbook name called Sales.Q1 could not be used in a macro,
alone or as `Name:Sales.Q1.cell(1,1)`.

The name now runs through periods, and the longest defined name or table that exists and ends
at a period (or the end) is used; what follows is read as methods. So `Name:Data.row` is a
name called Data.row, and `Name:Sales.offset(1,0)` still moves Sales. When no name matches,
the error names the text before the first method, and a bad method after a dotted name is
still refused. Tests cover direct resolution, reference parameters, `$ref` and `{{...}}`,
with and without a chained method.
2026-10-22 02:26:32 -04:00
prxnvt 3c74dfcbb0 Engine: renaming macros with clashing names on open takes near-linear work
Opening a workbook gave each clashing macro name a suffix by trying " (2)", " (3)"... in turn,
and each try scanned every macro accepted so far. A file with 1,000 macros of the same
255-character name made about 300 million name comparisons.

Load now keeps a set of the names in use (ASCII case-insensitive, as find compares them) and,
for each shortened name and suffix width, the next suffix to try, so each suffix is looked up
about once. The result is unchanged: the smallest free suffix, within the name length limit,
and the renamed macro still passes the final checks. unique_name keeps its signature. A test
opens, saves and reopens 1,000 same-name macros in JSON and xlsx and keeps them all, and
another counts lookups instead of timing them.
2026-10-22 02:26:32 -04:00
prxnvt efce63f75f Engine: macro recorder applies the macro policy to the steps of a recorded script run
An Automate script run while recording was replaced by all of its steps except automate.* ones,
without the policy check that top-level commands get. For example, a script that runs
window.activate or macro.list produced a recorded macro that the runner refused on playback. The
steps of a recorded script run, and of an inlined macro run, now go through the same policy as
top-level commands; refused ones are left out and listed as skipped.
2026-10-22 02:26:32 -04:00
prxnvt 62cececa63 Docs: debugging macros 2026-10-22 02:26:32 -04:00
prxnvt 06bbcb504a Engine: macro approvals are keyed on the macro's canonical JSON, not a 64-bit hash
Approvals were stored as (document, FNV-1a hash of the macro's JSON).
FNV-1a is not collision resistant, so a crafted workbook could hold a
harmless macro and a different one with the same hash, and approving
the first would let the second run without asking.

The key is now the macro's canonical JSON text itself (object keys
sorted), so two different macros never share an approval.
macros::hash is replaced by macros::fingerprint.
2026-10-22 02:26:32 -04:00
prxnvt 77150be455 Engine: col("Header") is refused on a range without a header row
col("...") always matched against the first row of the range, whatever
its header flag. On an input declared without a header row,
Input.col("Amount") silently picked the first column whose first data
cell read "Amount". After .data, the natural Input.data.col("Amount")
failed, because the first row was then a data row. The header row is
now carried through data, and col("...") is refused with a message when
there is none (an input without a header, a Name: base, or after any
other method).
2026-10-22 02:26:32 -04:00
prxnvt 7d31c5a4ed Engine: a macro in a newer format is dropped with a warning, not saved back as version 1
Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde
dropped the fields this build doesn't know, and the next save wrote the reduced macro back
with `v: 2`, so a newer build would read it as a complete version 2 macro.

A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks
the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is
left out with the usual single warning (which now names a newer format), and the rest of the
workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as
controls.
2026-10-22 02:26:32 -04:00
prxnvt 3f9223c85f Engine: macro recorder inlines a recorded macro run only when its steps stand alone
A macro run while recording was replaced by every command in it, including both branches of an
If and the body of a Repeat, whenever it had no inputs. For example, recording a run of a macro
with an If played back both the then and the else commands without a condition, and commands
using Row, Column or Output failed on playback. The run is now inlined only when the macro has no
inputs, no output and only plain commands (its disabled steps are left out); otherwise it is left
out and listed as skipped.
2026-10-22 02:26:32 -04:00
prxnvt ccea7a8db3 Engine: macro debugger reports a step that panics as the step's failure
When a debugged step panicked, the nested execute rolled the run back and dropped the debugger
before advance could reattach the machine, so the caller got "No macro is being debugged"
instead of the failed step. For example, stepping into a step whose command panics lost the
step path and the panic message. advance now sees that the run was already rolled back and
returns the usual failed result with the step error.
2026-10-22 02:26:32 -04:00
prxnvt b51d588cac Engine: macros can't open, hide or show windows
The policy denies window.* commands, but the View tab's window commands
are view.*: a macro could run view.newWindow, which adds a window that
the run's rollback doesn't remove, or view.hideWindow and
view.unhideWindow, which change the session's hidden windows.

The policy now denies those three. Arrange All changes nothing in the
engine and Switch Windows only lists windows, so both stay allowed.
2026-10-22 02:26:32 -04:00
prxnvt ac7c1ae639 Engine: a reference on a sheet named like a token stays a reference
is_token looked only at the leading identifier, so on a workbook with a
sheet called Output (or Row, Column, or the name of a declared input),
"Output!A1:B2" was read as the Output token, and the step failed with
"the macro has no output anchor" or "unexpected '!A1:B2'". This hit
reference keys, $ref and {{...}} alike, including references the
recorder writes for other sheets. A base followed by '!' is now a sheet
name, and the text resolves as a plain reference.
2026-10-22 02:26:32 -04:00
prxnvt ee7040a340 Docs: macro format and storage 2026-10-22 02:26:32 -04:00
prxnvt c6dda8562f Engine: macro recorder keeps references on other sheets as they are
The recorder made every unqualified reference relative to the example input, whatever sheet the
step ran on. For example, starting a recording on Sheet1 with B2:C5 selected, activating Sheet2
and setting C4 recorded Input.cell(3,2), so playback wrote Sheet1 instead; a formula entered on
Sheet2 was changed to refer to Sheet1's cells. Each step now keeps the sheet it ran on, and only
references on the input's sheet become tokens.
2026-10-22 02:26:32 -04:00
prxnvt 45e580ad91 Engine: macro.debug steps through a macro as one undo step
macro.debug runs a macro one step at a time. start checks the macro as
macro.run does (policy, approval, inputs, footprint and output rule)
and pauses before the first step; step runs one step; continue runs to
the next breakpoint; runTo runs to a chosen step path, stopping at
breakpoints on the way; stop ends the run and keeps its changes or
rolls them back; state reports where it is. Breakpoints are the steps'
own flags plus a list of paths given with any action (breakpointsOnly
makes the list replace the flags), and they stop in every pass of a
Repeat.

The run lives on the session between calls, inside an undo group that
spans them (group_begin and group_end), so a debugged run is one undo
step "Run Macro: <name>", as a normal run is. A failing step ends the
run and rolls it back; so does a Stop step that asks for a rollback.
While a macro is being debugged, edits of the workbooks the run touched
(its own, and any other a step changed) are refused at the top level
with "Finish or stop the macro first"; other workbooks stay editable,
and selecting and queries still work. Every answer gives the next
step's path, kind, command, parameters as they would run now (tokens
resolved), the Repeat pass, the parameters of the step that just ran,
the steps executed and the cells changed so far. Each action gets its
own time limit.

If a command panics while a step runs, the session's panic guard rolls
the debugged run back as well, so its undo group never stays open.
2026-10-22 02:26:32 -04:00
prxnvt bf456c9ff8 Engine: a macro's footprint dry runs keep to the run's timeLimit
footprint always ran its dry run with the default 60 second limit, so
macro.run with timeLimit 5 could run for over two minutes (a dry run,
then another after a shift) and fail with "ran longer than 60 seconds".

footprint now takes the run's clock and limit. make_way starts the
run's clock before the dry runs, so the checks and the run share one
limit, and macro.check takes timeLimit too.
2026-10-22 02:26:32 -04:00
prxnvt 7a0f591712 Docs: macro tokens 2026-10-22 02:26:32 -04:00
prxnvt 709a995290 Model: one damaged macro no longer stops a JSON workbook opening
Workbook::macros was read straight into typed macros, so one entry this
build couldn't read (a step type from a later build, an unknown
onConflict value, an entry that isn't an object) failed the whole file
with "not a GridCraft JSON workbook". The field is now read as raw
entries into Workbook::macros_read, and macros::load reads each one
with parse_macro, as for xlsx: short-form steps are accepted, the depth
limit is checked before deserializing, and a bad entry is dropped with
the same warning. Only the first 1,000 entries are read, as in xlsx.
2026-10-22 02:26:32 -04:00
prxnvt 252ffa95b6 Engine: macro recorder keeps a recording when saving it fails
Stopping a recording took the recorded commands and where recording started before saving the
macro, so a failed save threw both away. For example, stopping with a name that holds a control
character, or in a workbook at its macro limit, ended the recording, and stopping again with a
good name found nothing to save. A failed save now puts the recording back, so stopping again
saves what was recorded.
2026-10-22 02:26:32 -04:00
prxnvt 4c6962a97c Engine: a partial macro footprint no longer clears later writes; the run checks each step
The footprint dry run stops at an approved file step and at Solver, but
make_way took a partial footprint with no conflicts as proof that the
run was safe, also after a shift. A macro that exported a CSV and then
set B1 ran under onConflict fail or ask and overwrote B1 without asking.

When the footprint is partial and the rule is not overwrite, the run's
machine now checks every step as it runs: a step that writes over
cells, sheets or objects that held data when the run started stops the
run and rolls it back, with a confirmation under ask and an error
otherwise. make_way now returns the machine for the run along with
whether it shifted.
2026-10-22 02:26:32 -04:00
prxnvt 3bc1d91636 Engine: macro tokens, resolved to A1 references when a step runs
Step parameters can name where they act through tokens that are worked
out when the step runs, so a macro made on one block of data works on
another: a declared input (Input, or any other input's name), Output
(the output rule's anchor), Row or Column (the current item of the
Repeat step around the step) and Name:Total (a defined name or a
table's data). Methods narrow or move the range: col(n) or
col("Header"), row(n), cols(a..b), rows(2..), cell(r,c) relative to the
top-left cell, offset(r,c), resize(h,w), data (without the header row),
below(n) and right(n). Methods chain.

A token is written as {"$ref": "..."} anywhere, as the whole string of
a reference parameter (cell, range, target, keys ending in Cell or Range
and similar), or as {{...}} inside any string, which is how a formula
refers to its input. Other text parameters are never read as tokens, so
a cell can still hold the word Row and a chart can still be of type
column.

Every token becomes A1 text, sheet-qualified when it is on another
sheet, before the command runs, so no existing command changes. Plain A1
references in a step stay absolute. A reference that runs past the edge
of the sheet is reported apart from other mistakes, so the footprint
check can tell the two apart.
2026-10-22 02:26:32 -04:00
prxnvt 4966b9c86d Engine: opening a file reads its hidden macro names in one bounded pass
load listed every _gc_macro_ item, then read each one with a scan of
all the workbook's names, and removed each with another full scan.
Damaged items didn't count toward the macro limit, so a file with
20,000 damaged items took 20,000 scans of 20,000 names (seconds, and
far more for a larger file). The names are now taken out of the
workbook and grouped by item in one pass; only the first 1,000 items
are read, and the rest are counted in the warning without being read.
2026-10-22 02:26:32 -04:00
prxnvt 749558f978 Docs: recording macros and the management commands; roadmap, architecture and parity 2026-10-22 02:26:32 -04:00
prxnvt d305566e57 Docs: running and checking macros 2026-10-22 02:26:32 -04:00
prxnvt 7011395fcf Engine: a macro renamed on a name clash keeps its name within the length limit
When a file held two macros with the same name, the second was renamed
"name (2)" without room for the suffix. Two macros with the same
255-character name both opened, but the second name grew to 259
characters, was saved that way, and the next open dropped that macro as
over the limits. unique_name now shortens the name to make room for the
suffix, and load checks the macro again after renaming it.
2026-10-22 02:26:32 -04:00
prxnvt b2740bdf33 Engine: macro recorder and macro.list, get, save, delete, rename, record
Commands: macro.list, macro.get, macro.save (a macro object or its
fields; replace defaults to true), macro.delete, macro.rename and
macro.record, beside macro.run, macro.check and macro.debug. Saving,
renaming and deleting are undoable. The catalog gains View › Macros ›
Record Macro (parity 275 / 291).

The recorder builds on the recording buffer of top-level commands, so
nested commands are never recorded twice and trimming the journal loses
nothing. When it stops, commands a macro may not run are left out and
listed; a macro run while recording is replaced by its steps when it
has no inputs, and an Automate script run by the script's steps;
sheet.activate and selection.set steps are added where the sheet or
selection changed between commands without one, and runs of them
collapse to the last. With relative references (the default) the
selection when recording started is the example input: references in
and near it become tokens (Input.col(2), Input.rows(2..),
Input.col(2).below, Input.row(4).right, Input.cell(r,c)), exact
references in formulas become {{...}}, and the input's width, header
and column types are inferred from the example. A recording replayed on
a longer block of data writes in the matching places.

A toggle such as Bold is recorded with the state it set ("on": true),
as Excel records Bold = True, so a replay sets it instead of flipping
it again.

Automate scripts are unchanged: automate.* keep their commands,
parameters, storage and results, and View › Macros still lists the
scripts and a preserved VBA project.
2026-10-22 02:26:32 -04:00
prxnvt 23c0107511 Engine: macro policy checks a step's parameters after tokens resolve; any path counts as file access
A path given as a token (`{"$ref": "Input"}` or `{{Input}}`) became a file name only when the
step ran, after the policy had passed it, so a macro without permissions or approval could write
a file, even from macro.check. Exports also wrote to blank paths the policy ignored. Any `path`
other than null now needs the permission and approval, and the runner checks the policy again on
the resolved parameters before the command runs.
2026-10-22 02:26:32 -04:00
prxnvt 20d2a3b998 Model: a macro name with control characters is refused on every path
Only parse_macro refused a name holding a line break or other control
character. Macros that came from the macros field of a JSON workbook
were checked with check_limits alone, so a file could bring in a macro
named "a\nb" that macro.save and macro.rename would refuse. The check
now lives in Macro::check_limits, so every path applies it, and such a
macro is dropped with the usual warning when the file is opened.
2026-10-22 02:26:32 -04:00
prxnvt 54c08645b9 Engine: macro.run and macro.check: input checks, footprint dry run, policy
macro.run runs a macro as one undo step ("Run Macro: <name>"); an error
at any step rolls everything back and names the step and its path (3,
2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts:

- Policy (macro_policy). File, app, window, automate and macro commands
  and Undo and Redo never run in a macro. Reading or writing files
  (data.getData, data.fromTextCsv, insert.picture, Page Layout
  Background, or an export with a path) needs the command in the
  macro's permissions and the person's approval. A macro in a workbook
  opened from a file asks before it runs. Approval holds for the
  session until the macro's content changes; it is asked for with the
  new EngineError::Approve and given with approve: true. A step that
  would open a dialog or a web page fails instead.
- Inputs. Each declared input is bound (given, its default, or the
  selection for the first one), whole columns shrink to the used range,
  and the shape and column types are checked with messages that name
  the cells: "Input column 2 (Amount) must hold numbers; C4 holds text".
- Footprint. Unless the macro may overwrite, it first runs in dry-run
  mode on the real workbook inside an undo group that is rolled back,
  with the session's clipboard, journal, windows and the document's
  trust put back. CellStore::diff on every sheet, plus the cells
  commands wrote (DocState::touched), sheets and objects, gives what it
  would write outside its inputs; cells that only recalculated do not
  count. Writing over data, deleting a sheet or deleting an object is a
  conflict: ask (the new EngineError::Confirm, so programmatic runs fail
  unless they pass onConflict or overwrite), fail, overwrite, or shift
  the data down or right with Insert Cells and check again. A step that
  would fail, for example past the edge of the sheet, fails the run
  before anything changes. File steps and Solver stop the dry run early
  and are reported.
- Steps run on a machine with an explicit frame stack: Repeat over the
  rows or columns of a range with Row or Column bound, If on one cell
  with Excel's comparisons, and Stop, which keeps the changes or rolls
  them back. A run executes at most 100,000 steps within a time limit
  (60 s by default, timeLimit to change; the web build has no clock, so
  only the step limit applies there).

macro.check reports the bound inputs, the output anchor, the approval
needed, the footprint and the conflicts without changing anything;
nothing from an unapproved macro runs, not even as a dry run.
2026-10-22 02:26:32 -04:00
prxnvt a4b4872602 Model: macros as typed command lists, saved in the workbook
A macro is plain data in the model (model/src/macros.rs): a name, a
description, an optional shortcut, declared inputs (shape, header,
column types, non-empty, default reference), steps, an output rule
(anchor and what to do on a conflict) and the commands it may run that
read or write files. A step runs a command (id and params) or is one of
three bounded control steps: Repeat over the rows or columns of a range,
If on one cell's value, and Stop with a message. Every step carries
enabled, breakpoint and note flags. These are GridCraft macros, not VBA;
a VBA project in a file stays preserved and is never run.

Workbook::macros holds them. The JSON format keeps the field. In xlsx,
io::save_bytes writes each macro as one chunked hidden-name item,
_gc_macro_<n>, and io::open_bytes takes the items back out of the names,
so the names never linger in the open document.

Limits are checked whenever a macro is read from a file or a command:
at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep,
names and notes of bounded length, and at most 4,194,304 characters of
JSON, so that saving a workbook never fails because of a macro; nesting
is checked before the steps are deserialized. What is over or damaged
is dropped with one warning, and clashing names get a suffix.

Automate scripts keep their own storage (_sc_scripts) and are neither
read nor changed by this.
2026-10-22 02:26:32 -04:00
prxnvt 471114e296 Engine: a visible name of the item shape is not workbook data
The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.
2026-10-22 02:24:55 -04:00
prxnvt 2287f63a95 Engine: a group rolled back inside a command keeps the redo history
A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.
2026-10-22 02:00:54 -04:00
prxnvt eb3eaa052d Engine: a command keeps its group's name only for a group it ended itself
The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.

The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.
2026-10-22 01:36:01 -04:00
prxnvt 0457f102a0 Engine: pasting only formats from a file leaves the destination's trust alone
Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.

Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.
2026-10-21 17:27:20 -04:00
prxnvt ab27d35386 Engine: a full undo history keeps one step per command that runs its own undo groups
When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.

The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.
2026-10-21 17:25:45 -04:00
prxnvt f4e05534a3 Engine: a command that runs its own undo groups makes one undo step for itself
execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.
2026-10-21 15:23:08 -04:00
prxnvt 83ae63f793 Engine: a stash item with only later chunks reads as missing and falls back to the legacy name
With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists.
2026-10-21 15:18:15 -04:00
prxnvt e165eaf3a6 Engine: a linked paste of values from a file carries the file's trust
A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.
2026-10-21 15:18:15 -04:00
prxnvt 7d1fee8c78 Engine: workbook data in chunked hidden names (cmd/stash.rs)
GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.

cmd/stash.rs stores an item `key` as hidden workbook-scope names
_gc_<key>_0, _gc_<key>_1, ..., each a formula of string literals of at
most 255 characters (UTF-16 units, as Excel counts them) joined with &,
at most 8,192 characters per name. Reading concatenates the chunks in
order and stops at a gap; it also accepts the older single _sc_<key>
name. Names are matched case-insensitively and only at workbook scope.
Keys are limited to letters, digits, _ and ., items to 4,194,304
characters, and junk around or inside the literals, duplicate or
out-of-order chunks, non-JSON and over-deep JSON all read safely.
keys(prefix) lists the stored items that start with a prefix.

Custom views, scripts, the watch list and scenarios keep their current
storage; nothing reads or writes the new names yet.

docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.
2026-10-21 14:05:48 -04:00
prxnvt b7bce3bc09 Engine: document trust for workbooks opened from files
Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.

The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
2026-10-21 14:05:48 -04:00
prxnvt d4f1e61c99 Engine: a recording buffer of top-level commands, apart from the journal
A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.

Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.

Automate › Record Actions keeps recording through the journal as before.
2026-10-21 14:05:41 -04:00
prxnvt acfd38f309 Engine: undo groups make several commands one undo step
Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.

While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.

Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
2026-10-21 14:05:41 -04:00
Daniel c01e46619a fix(linux): keep font-free GNOME Wayland controls scoped to packaging and build 2026-10-20 03:36:51 +02:00
Gert Pellin 9d9911dca1 Merge branch 'main' into fix/1419-clear-skips-filtered-rows 2026-10-15 22:25:42 +02:00
Gert Pellin d280e6ff00 Clear: Delete and the Clear commands skip the rows a filter hides (#1419)
As in Excel, clearing a filtered selection leaves the rows the filter
hides as they are; rows hidden by hand are still cleared. Checked boxes
in hidden rows no longer hold back Delete on the boxes showing.
2026-10-15 04:44:22 +02:00
344 changed files with 45899 additions and 1740 deletions
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
+2 -2
View File
@@ -19,8 +19,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: gridcraft
app-dir: GridCraft
+10 -10
View File
@@ -2,8 +2,6 @@
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
## Never crash
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
@@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## Contributor credits (About window)
@@ -62,11 +60,13 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+11 -9
View File
@@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | |
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
@@ -46,3 +46,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/pivot.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
| `docs/images/format-chart.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
| `docs/images/page-break.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds no font: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font |
| Unicode values of the Symbol encoding in `crates/pdf/src/symbol.rs` (the code-to-character map; numbers only, no file copied) | Unicode, Inc. | `symbol.txt`, "Adobe Symbol Encoding to Unicode", table version 1.0 (2011-07-12), at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/ | Unicode, Inc.'s notice in the file: "Unicode, Inc. hereby grants the right to freely use the information supplied in this file in the creation of products supporting the Unicode Standard, and to make copies of this file in any form for internal or external distribution as long as this notice remains attached." The file is provided as-is, without warranties | Separate from Adobe's Core 14 notice (previous row), which covers the character codes and widths; the Unicode values come from this table |
+9 -6
View File
@@ -2,8 +2,6 @@
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
## Never crash
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
@@ -50,4 +48,9 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+259 -43
View File
@@ -20,7 +20,7 @@ dependencies = [
"accesskit",
"accesskit_consumer",
"atspi-common",
"phf",
"phf 0.14.0",
"serde",
"zvariant",
]
@@ -220,11 +220,17 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -468,15 +474,30 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec",
"bit-vec 0.9.1",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -889,12 +910,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -917,9 +938,10 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree",
"tempfile",
@@ -930,7 +952,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -950,13 +972,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -975,7 +998,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -1455,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1493,6 +1516,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1935,7 +1969,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"arboard",
"chrono",
@@ -1962,7 +1996,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1974,7 +2008,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1985,7 +2019,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1997,7 +2031,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"chrono",
"js-sys",
@@ -2008,13 +2042,14 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
"gridcraft-math",
"gridcraft-model",
"gridcraft-numfmt",
"gridcraft-pdf",
@@ -2030,7 +2065,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -2039,15 +2074,25 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-core",
"regex",
]
[[package]]
name = "gridcraft-math"
version = "0.16.0"
dependencies = [
"ratex-font",
"ratex-layout",
"ratex-parser",
"ratex-types",
]
[[package]]
name = "gridcraft-mcp"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -2056,10 +2101,11 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
"gridcraft-math",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -2068,18 +2114,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.13.0"
version = "0.16.0"
[[package]]
name = "gridcraft-ui-egui"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -2104,7 +2150,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"eframe",
"egui",
@@ -2121,12 +2167,13 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"aes",
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
"gridcraft-math",
"gridcraft-model",
"quick-xml 0.41.0",
"sha1 0.11.0",
@@ -2965,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
"bit-set 0.10.0",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -3460,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3541,17 +3588,37 @@ dependencies = [
"indexmap",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
"phf_macros 0.14.0",
"phf_shared 0.14.0",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.8",
]
[[package]]
name = "phf_generator"
version = "0.14.0"
@@ -3559,7 +3626,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
"phf_shared 0.14.0",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -3568,13 +3648,22 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.14.0"
@@ -3850,7 +3939,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@@ -3874,7 +3963,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3898,6 +3987,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.3"
@@ -3936,6 +4034,66 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "ratex-font"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db"
dependencies = [
"phf 0.11.3",
"ratex-types",
]
[[package]]
name = "ratex-layout"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7"
dependencies = [
"ratex-font",
"ratex-parser",
"ratex-types",
"serde_json",
]
[[package]]
name = "ratex-lexer"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e"
dependencies = [
"ratex-types",
"serde",
"thiserror 1.0.69",
]
[[package]]
name = "ratex-parser"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0"
dependencies = [
"fancy-regex",
"ratex-font",
"ratex-lexer",
"ratex-types",
"regex",
"regex-lite",
"serde",
"serde_json",
"thiserror 1.0.69",
"unicode-normalization",
]
[[package]]
name = "ratex-types"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900"
dependencies = [
"serde",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -4035,6 +4193,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -4184,7 +4348,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4243,7 +4407,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4312,6 +4476,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4615,7 +4790,7 @@ dependencies = [
"const-oid",
"der",
"hex",
"rand",
"rand 0.10.3",
"reqwest",
"rustls-pki-types",
"rustls-webpki",
@@ -4844,6 +5019,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strum"
version = "0.28.0"
@@ -4977,7 +5158,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5073,6 +5254,31 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -5344,6 +5550,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -5784,8 +5999,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set",
"bit-vec",
"bit-set 0.10.0",
"bit-vec 0.9.1",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -5857,7 +6072,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set",
"bit-set 0.10.0",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
@@ -5936,7 +6151,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6183,6 +6398,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -6402,7 +6618,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.13.0"
version = "0.16.0"
dependencies = [
"gridcraft-engine",
"serde_json",
+12 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.13.0"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -24,9 +24,11 @@ gridcraft-engine = { path = "crates/engine" }
gridcraft-ui-egui = { path = "crates/ui-egui" }
gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
gridcraft-math = { path = "crates/math" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -46,6 +48,13 @@ aes = { version = "0.9", default-features = false }
sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
# young (0.1.x), and the gate recorded in docs/decisions.md vetted this release.
ratex-parser = "=0.1.14"
ratex-layout = "=0.1.14"
ratex-types = "=0.1.14"
ratex-font = "=0.1.14"
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
@@ -62,7 +71,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+31
View File
@@ -274,3 +274,34 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9 -1
View File
@@ -298,7 +298,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+34 -5
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -86,7 +86,7 @@ collaboration, mobile, AI).
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
## Languages
@@ -135,7 +135,20 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
| 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else |
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
@@ -166,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
@@ -179,6 +193,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
@@ -231,7 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
@@ -242,7 +257,20 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
| 2026-11-02 | minor | Progress log: Python cells as data |
| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) |
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
| 2026-10-31 | minor | Progress log: Function Library |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
| 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) |
@@ -258,6 +286,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
@@ -292,5 +321,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` |
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` |
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
+3 -2
View File
@@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
// With `noopener` the new browsing context can't hold a reference back to the
// editor window and navigate it while the user reads the linked page.
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
// editor window and navigate it while the user reads the linked page. It must be a
// window *feature*: inside the target string it would only name the new window.
let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer");
}
})),
inbox: Some(inbox),
+10 -2
View File
@@ -24,13 +24,17 @@ chrono = { workspace = true }
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Retain X11 fallback; draw Wayland client-side controls without bundled title fonts.
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
@@ -52,6 +56,10 @@ muda = { version = "0.21", default-features = false }
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda,
# winit); pinned exactly to the same versions.
objc2 = "=0.6.5"
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
# pinned exactly to the same versions.
+28 -1
View File
@@ -11,6 +11,8 @@
use fmv_macos_events::{Event, Inbox, Registration};
use gridcraft_ui_egui::SheetApp;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSRequestUserAttentionType};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
@@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
}
}
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
Event::Quit => app.request_quit(ctx),
Event::Quit => {
app.request_quit(ctx);
if app.dialog.is_some() {
bring_app_forward();
}
}
}
}
}
/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to
/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and
/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock
/// icon until the user switches over.
fn bring_app_forward() {
let Some(mtm) = MainThreadMarker::new() else { return };
let ns_app = NSApplication::sharedApplication(mtm);
ns_app.unhide(None);
for window in ns_app.windows().to_vec() {
if window.isMiniaturized() {
window.deminiaturize(None);
}
}
#[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version.
ns_app.activateIgnoringOtherApps(true);
if !ns_app.isActive() {
ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest);
}
}
+164 -15
View File
@@ -12,16 +12,47 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::time::{Duration, Instant};
use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
/// How long a new connection may take to send its token.
/// How long a new connection may take to send its token, in total.
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
/// How many connections may be waiting to authenticate at once. Anyone on the machine can
/// connect without the token, so past this a new connection is closed before anything is read:
/// each waiting one holds a thread and a socket.
const MAX_PENDING_AUTH: usize = 16;
/// How the server treats connections that haven't authenticated yet.
#[derive(Clone, Copy)]
struct AuthLimits {
timeout: Duration,
max_pending: usize,
}
const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };
/// A connection that is still waiting to authenticate; dropping it frees its place.
struct Pending(Arc<AtomicUsize>);
impl Pending {
/// A place among the `max` waiting connections, or `None` when they are all taken.
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;
Some(Pending(Arc::clone(waiting)))
}
}
impl Drop for Pending {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
/// else 256 random bits from the operating system, written to a file only the user can read.
@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
}
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
let waiting = Arc::new(AtomicUsize::new(0));
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
for stream in listener.incoming() {
let Ok(stream) = stream else {
// Out of sockets or handles: wait for some to free instead of spinning on the error.
std::thread::sleep(Duration::from_millis(50));
continue;
};
// Dropping the stream closes it before anything is read.
let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
let _ =
std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));
}
})?;
Ok(rx)
}
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
let mut first = String::new();
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
let verdict = match read {
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
/// `timeout` in total) and check the token. `true` when the peer may go on.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {
let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
let mut first = Vec::new();
let read = read_auth_line(reader, &mut first, deadline);
let verdict = match (read, std::str::from_utf8(&first)) {
(Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token),
_ => Err(json!({"ok": false, "error": "authentication required"})),
};
let allowed = verdict.is_ok();
let reply = verdict.unwrap_or_else(|e| e);
let _ = writeln!(out, "{reply}");
let _ = out.set_read_timeout(None);
let _ = reader.get_ref().set_read_timeout(None);
allowed
}
/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.
///
/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps
/// its own, so one set on the writing half never fired (a silent peer held its thread forever).
/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {
let max = control_auth::MAX_AUTH_LINE;
while line.len() < max && line.last() != Some(&b'\n') {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::ErrorKind::TimedOut.into());
}
reader.get_ref().set_read_timeout(Some(left))?;
let buf = reader.fill_buf()?;
if buf.is_empty() {
break;
}
let room = max.saturating_sub(line.len());
let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);
line.extend(buf.iter().take(take));
reader.consume(take);
}
Ok(())
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
if !authenticate(&mut reader, &mut out, token) {
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
// Authenticated or refused, the connection no longer counts as waiting.
drop(place);
if !allowed {
return;
}
let mut buf = Vec::new();
@@ -294,7 +368,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
}
});
addr
@@ -346,6 +420,81 @@ mod tests {
assert_eq!(got[1]["ok"], json!(false));
}
/// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within
/// `within`, and how long it took.
#[allow(clippy::disallowed_methods)] // a native-only test of the desktop app
fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {
let start = Instant::now();
c.set_read_timeout(Some(within)).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
loop {
line.clear();
match r.read_line(&mut line) {
Ok(0) => return (true, start.elapsed()),
Ok(_) => continue,
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),
Err(_) => return (true, start.elapsed()),
}
}
}
/// The token deadline is for the whole first line: neither a silent peer nor one sending a
/// byte at a time keeps the connection (and its thread) past it.
#[test]
fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let silent = TcpStream::connect(addr).unwrap();
let mut slow = TcpStream::connect(addr).unwrap();
let mut drip = slow.try_clone().unwrap();
std::thread::spawn(move || {
for b in control_auth::auth_line(1, TOKEN).bytes() {
if drip.write_all(&[b]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
});
let (closed, took) = closed_within(silent, Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");
let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");
let _ = slow.flush();
}
/// Peers without the token can't pile up threads: past the cap a new connection is closed at
/// once, and a waiting one that authenticates frees its place.
#[test]
fn connections_waiting_to_authenticate_are_capped() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let mut first = TcpStream::connect(addr).unwrap();
let _second = TcpStream::connect(addr).unwrap();
std::thread::sleep(Duration::from_millis(200));
let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");
first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");
std::thread::sleep(Duration::from_millis(200));
let mut next = TcpStream::connect(addr).unwrap();
next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(next).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");
}
#[test]
fn a_chosen_token_must_be_long_enough() {
assert!(make_token(1, Some("short".into())).is_err());
+78 -2
View File
@@ -1,4 +1,5 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
//! Graphics instance configuration, applied before wgpu can initialize any driver: Windows
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
//!
//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters
//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access
@@ -8,12 +9,38 @@
//! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see
//! [`dx12_compiler`].
// On macOS only the indirect-validation items are used outside tests; on Windows the other way
// round, so those two carry their own `allow`.
#![cfg_attr(target_os = "macos", allow(dead_code))]
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType, Dx12Compiler},
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
};
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On Intel
/// Macs (Iris 5100 in a Mac mini 2014) Metal's compiler aborts on them ("Compiler encountered an
/// internal error"), the device is lost and eframe exits before the window opens. GridCraft issues
/// no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's default.
/// Same as WordCraft (storytold/wordcraft#2652) and PhotoCraft (storytold/PhotoCraft#3171).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
if !skip {
return;
}
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
}
/// What the start-up probe learned about one DirectX 12 adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Dx12Adapter {
@@ -141,6 +168,55 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
mod tests {
use super::*;
fn instance_flags(options: &NativeOptions) -> InstanceFlags {
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.flags
}
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
}
let mut options = NativeOptions::default();
let before = instance_flags(&options);
configure_indirect_validation(&mut options, false);
assert_eq!(instance_flags(&options), before, "flags untouched when not skipping");
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
configure_indirect_validation(&mut options, true);
assert!(!instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the validation back on even when it is skipped.
/// Tests can't set environment variables (`set_var` is unsafe, and unsafe code is forbidden),
/// so this runs itself again as a child process with the variable set.
#[test]
fn the_environment_turns_indirect_validation_back_on() {
const NAME: &str = "graphics::tests::the_environment_turns_indirect_validation_back_on";
if std::env::var_os("GRIDCRAFT_INDIRECT_VALIDATION_CHILD").is_some() {
let mut options = NativeOptions::default();
configure_indirect_validation(&mut options, true);
assert!(instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
return;
}
let exe = std::env::current_exe().unwrap();
let out = std::process::Command::new(exe)
.args(["--exact", NAME, "--test-threads=1"])
.env("GRIDCRAFT_INDIRECT_VALIDATION_CHILD", "1")
.env("WGPU_VALIDATION_INDIRECT_CALL", "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
// `--exact` matching nothing would pass too, so the child must report this one test.
assert!(out.status.success() && stdout.contains("1 passed"), "child test failed:\n{stdout}{}", String::from_utf8_lossy(&out.stderr));
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+11 -7
View File
@@ -12,7 +12,7 @@
#[cfg(target_os = "macos")]
mod apple_events;
mod control_server;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "macos", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
@@ -256,7 +256,7 @@ fn main() -> eframe::Result<()> {
}));
let args: Vec<String> = std::env::args().skip(1).collect();
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("GridCraft update helper failed: {e}");
std::process::exit(1);
@@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> {
{
log::warn!("sample {s}: {e}");
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.start_update_check();
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
// Stamp the startup workbook's author from the persisted user name.
if app.session.documents().is_empty() {
app.session.new_workbook();
}
app.after_engine(); // Show warnings from the sample.
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
// opens them: a password-protected one asks for its password, and errors are shown, not
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
@@ -340,8 +341,11 @@ fn main() -> eframe::Result<()> {
}
let window_builder = viewport.clone();
let options = eframe::NativeOptions { viewport, ..Default::default() };
#[cfg(all(unix, not(target_os = "macos")))]
#[cfg(unix)]
let mut options = options;
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (see graphics.rs).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
+8 -7
View File
@@ -390,11 +390,12 @@ impl NativeMenu {
#[cfg(test)]
mod tests {
use super::*;
// No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test
// harness, so the #[test] functions are left out and a module-level import would be unused.
#[test]
fn file_menu_has_export_as_pdf_dispatching_the_command() {
let tree = tree();
let tree = super::tree();
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
assert_eq!(item.1, "file.exportPdf");
@@ -403,23 +404,23 @@ mod tests {
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
let tree = tree();
let tree = super::tree();
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
assert_eq!(item.1, "app.checkForUpdates");
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
assert!(app.run(item.1, serde_json::json!({})).is_ok());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
for mode in ["dark", "system", "light"] {
assert!(select_theme(&mut app, &format!("theme:{mode}")));
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
assert_eq!(app.ui.theme_mode(), mode);
assert!(app.message.is_none());
}
assert!(!select_theme(&mut app, "view.formulaBar"));
assert!(!super::select_theme(&mut app, "view.formulaBar"));
assert_eq!(app.ui.theme_mode(), "light");
}
}
+13 -2
View File
@@ -19,12 +19,23 @@ fn main() {
nsapp.finishLaunching();
let ctx = egui::Context::default();
let _menu = native_menu::NativeMenu::install(&ctx);
let mut menu = native_menu::NativeMenu::install(&ctx);
let bar = nsapp.mainMenu().expect("a main menu");
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu");
assert_eq!(windows_menu.title().to_string(), "Window");
// A poll lists the open workbook windows at the end of the Window menu (#716).
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default());
menu.poll(&mut app, &ctx, egui::ViewportId::ROOT);
let open = app.open_windows();
assert_eq!(open.len(), 1, "one workbook window");
let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item");
assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook");
println!("mac_menu_appkit: ok");
}
+1 -1
View File
@@ -1,7 +1,7 @@
# GridCraft app icon (PLACEHOLDER)
**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the
product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet.
product owner makes; GridCraft's hasn't been made yet.
Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number
column, a selected cell with its fill handle and a double rule under a total, on a full-bleed
GridCraft-green tile. It is original geometry written by hand for this repository, deliberately
+1 -1
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
Original work; temporary until the owner's engraved creature portrait replaces it
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
Palette: ink #0b0b0c, paper #efe9dc,
app colour #1f9d55 (proposed). -->
<defs>
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+11 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -103,3 +100,12 @@ real_name = "Helen Kwok"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Plutonium-fream]
sync_github_name = true
[people.maxm11]
sync_github_name = true
+29 -2
View File
@@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above
/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap.
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?;
if cells > MAX_CELLS {
return None;
}
usize::try_from(cells).ok()
}
fn broadcast_dim(a: usize, b: usize) -> usize {
@@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
pub(super) fn single(v: Value) -> Option<Value> {
match v {
Value::Array(a) if a.rows * a.cols > 1 => None,
Value::Array(a) if a.data.len() > 1 => None,
v => Some(v.scalar()),
}
}
@@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
}
}
#[cfg(test)]
mod tests {
use super::{MAX_CELLS, broadcast_len};
#[test]
fn broadcast_len_caps_at_max_cells() {
assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize));
assert_eq!(broadcast_len(4_000, 4_001), None);
assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize));
}
/// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a
/// `> MAX_CELLS` test would let through; the guard must reject the true product.
#[test]
fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() {
assert_eq!(65_536u32.wrapping_mul(65_536), 0);
assert_eq!(broadcast_len(65_536, 65_536), None);
assert_eq!(broadcast_len(1_048_576, 16_384), None);
}
}
+69 -1
View File
@@ -135,6 +135,10 @@ impl Calc {
if cell.value == Value::Error(CellError::Circ) {
circ.push((si, c));
}
} else if cell.py.is_some() {
// A Python cell lays out its kept result, so it is calculated like a formula
// that reads nothing: its spill is blocked and unblocked like a formula's.
self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false });
}
}
}
@@ -206,11 +210,29 @@ impl Calc {
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
for &k in changed {
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
let python = py.is_some();
// A Python cell whose result (if any) is not an array spills nothing.
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
match formula.and_then(|f| f.expr()) {
Some(e) => {
let (areas, dynamic) = precedents(wb, k.0, &e);
self.graph.insert(k, Node { areas, dynamic });
}
// See `rebuild`: its spill is laid out again from its result when it is calculated.
None if python => {
self.graph.insert(k, Node { areas: Vec::new(), dynamic: false });
// New code has no result yet: the spill the cell had (its old result's, or a
// formula's) goes, and what read it is recalculated, as for a formula
// replaced by a constant.
if !spills
&& let Some(sh) = wb.sheet_mut(k.0)
&& let Some(r) = sh.spill_ranges.remove(&k.1)
{
sh.clear_spill(r);
cleared.push((k.0, r));
}
}
None => {
self.graph.remove(k);
// A formula replaced by a constant (or cleared) takes its spilled values with it.
@@ -338,11 +360,15 @@ impl Calc {
exprs.insert(*k, e);
}
}
let python: HashMap<Key, Value> = dirty
.iter()
.filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v)))
.collect();
let now = self.now();
let mut rng = self.rng;
let tp = prof_now();
let (results, spills, cycles) = {
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python);
host.iterate = wb.calc.iterative;
for k in &dirty {
host.calculate(*k);
@@ -547,6 +573,48 @@ pub(crate) mod tests {
v
}
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
let n = 1_000usize;
let data: Vec<Value> = (0..n * n).map(|i| Value::Number(i as f64)).collect();
let array = gridcraft_core::Array::new(n, n, data.clone()).unwrap();
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
let anchor = CellRef::new(0, 0);
let py = gridcraft_model::PyCode::new("big");
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
let area = RangeRef::new(anchor, CellRef::new(n as u32 - 1, n as u32 - 1));
sh.spill_ranges.insert(anchor, area);
for (i, c) in area.iter().enumerate().skip(1) {
sh.spill.insert(c, data[i].clone());
}
let mut python = HashMap::default();
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
(wb, python)
}
#[test]
fn a_read_beside_a_large_spill_walks_none_of_it() {
let (wb, python) = big_python_spill();
let exprs = HashMap::default();
let mut rng = 1u64;
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
assert_eq!(host.cell_value(0, CellRef::new(0, 0)), Value::Number(0.0));
// Beside the spill (column ALM, the 1,001st), over the rows it covers.
let beside = RangeRef::parse("ALM1:ALM1000").unwrap();
host.spill_visits = 0;
assert!(host.range_values(0, beside).iter().all(Value::is_empty));
assert!(host.visit_range(0, beside, &mut |_| true), "a disjoint read takes the fast path");
assert_eq!(host.spill_visits, 0, "nothing of the spill is walked");
// A read inside it still sees the spilled values, walking only the cells it reads.
let inside = RangeRef::parse("B2:C3").unwrap();
host.spill_visits = 0;
assert_eq!(host.range_values(0, inside), [1_001.0, 1_002.0, 2_001.0, 2_002.0].map(Value::Number).to_vec());
assert!(host.spill_visits <= 8, "{}", host.spill_visits);
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
}
#[test]
fn intervals_match_a_linear_scan() {
let mut iv: Intervals<u32> = Intervals::default();
+145 -36
View File
@@ -45,6 +45,10 @@ pub(super) struct PassHost<'a> {
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
#[cfg(test)]
pub(super) suspensions: usize,
/// Spilled values looked at by range reads (tests check that a read outside a large spill
/// looks at none of it).
#[cfg(test)]
pub(super) spill_visits: usize,
/// The formulas in `cycles`.
cycle_seen: HashSet<Key>,
/// Iterative calculation: a circular reference reads the cell's previous value instead of
@@ -64,6 +68,10 @@ pub(super) struct PassHost<'a> {
stack_top: usize,
/// Whether an evaluation ran past the stack budget since the innermost formula started.
out_of_stack: bool,
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
python: HashMap<Key, Value>,
}
impl<'a> PassHost<'a> {
@@ -87,6 +95,8 @@ impl<'a> PassHost<'a> {
also: Vec::new(),
#[cfg(test)]
suspensions: 0,
#[cfg(test)]
spill_visits: 0,
cycle_seen: HashSet::default(),
iterate: false,
prev: HashMap::default(),
@@ -96,8 +106,17 @@ impl<'a> PassHost<'a> {
taint: false,
stack_top: 0,
out_of_stack: false,
python: HashMap::default(),
}
}
/// Adds the dirty Python cells of the pass and their kept results: like the formulas, they
/// are pending until laid out, so every read path calculates them before reading them.
pub(super) fn with_python(mut self, python: HashMap<Key, Value>) -> Self {
self.pending.extend(python.keys().copied());
self.python = python;
self
}
}
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
@@ -111,7 +130,10 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
let own = sh.spill_ranges.get(&anchor);
let spilled_over = |c: CellRef| {
!own.is_some_and(|o| o.contains(c))
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
&& sh.spill_ranges.iter().any(|(o, r)| {
// A formula or Python cell recalculated in this pass lays its array out again.
*o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o))
})
};
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
@@ -135,6 +157,42 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
}
/// The area an array anchored at `anchor` covers.
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
let end =
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
RangeRef::new(anchor, end)
}
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
/// Returns how many values were looked at.
fn stored_spill_in<'s>(sheet: &'s gridcraft_model::Sheet, range: RangeRef, f: &mut dyn FnMut(CellRef, &'s Value) -> bool) -> usize {
let mut seen = 0usize;
if sheet.spill.is_empty() {
return 0;
}
if (range.height() as usize) < sheet.spill.len() {
for row in range.start.row..=range.end.row {
for (c, v) in sheet.spill.range(CellRef::new(row, range.start.col)..=CellRef::new(row, range.end.col)) {
seen += 1;
if !f(*c, v) {
return seen;
}
}
}
} else {
for (c, v) in sheet.spill.range(range.start..=range.end) {
seen += 1;
if range.contains(*c) && !f(*c, v) {
return seen;
}
}
}
seen
}
impl PassHost<'_> {
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
@@ -188,6 +246,9 @@ impl PassHost<'_> {
}
return v;
}
if let Some(result) = self.python.get(&k).cloned() {
return self.python_result(k, result);
}
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
@@ -305,6 +366,36 @@ impl PassHost<'_> {
v
}
/// A Python cell's kept result as its value: an array spills from the cell unless it is
/// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a
/// crafted file holds one), which shows `#SPILL!`.
fn python_result(&mut self, k: Key, result: Value) -> Value {
self.pending.remove(&k);
let v = match result {
Value::Array(a) => {
let sane = a.rows >= 1
&& a.cols >= 1
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
if sane {
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
} else {
Value::Error(CellError::Spill)
}
}
v => v,
};
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
Value::Error(CellError::Spill)
}
_ => v,
};
self.results.insert(k, v.clone());
v
}
/// A formula's previous value for iterative calculation: from the last iteration, else the
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
fn previous(&self, k: Key) -> Value {
@@ -445,26 +536,29 @@ impl Host for PassHost<'_> {
let (h, w) = (range.height() as usize, range.width() as usize);
let mut out = vec![Value::Empty; h * w];
let Some(sh) = self.wb.sheet(sheet) else { return out };
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
// `compute`, constants are copied straight from the store.
// Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go
// through `compute`, constants are copied straight from the store.
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
for (c, cell) in sh.cells.iter_range(range) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if cell.formula.is_some() {
if cell.formula.is_some() || cell.py.is_some() {
formulas.push((i, c));
} else if let Some(slot) = out.get_mut(i) {
*slot = cell.value.clone();
}
}
for (c, v) in sh.spill.range(range.start..=range.end) {
if range.contains(*c) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if let Some(slot) = out.get_mut(i)
&& slot.is_empty()
{
*slot = v.clone();
}
let _seen = stored_spill_in(sh, range, &mut |c, v| {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if let Some(slot) = out.get_mut(i)
&& slot.is_empty()
{
*slot = v.clone();
}
true
});
#[cfg(test)]
{
self.spill_visits += _seen;
}
for (i, c) in formulas {
let v = self.cell_value(sheet, c);
@@ -473,26 +567,31 @@ impl Host for PassHost<'_> {
}
}
if !self.spills.is_empty() {
let new: Vec<(usize, Value)> = self
.spills
.iter()
.filter(|(a, _)| a.0 == sheet)
.flat_map(|(a, arr)| {
let mut v = Vec::new();
for r in 0..arr.rows {
for cc in 0..arr.cols {
let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32));
if c != a.1 && range.contains(c) {
v.push((
(c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize,
arr.get(r, cc).cloned().unwrap_or_default(),
));
}
// Only the part of each array inside the range is walked: a large array beside a small
// read costs nothing.
let mut new: Vec<(usize, Value)> = Vec::new();
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
let area = array_area(a.1, arr);
if !area.intersects(&range) {
continue;
}
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
for row in r0..=r1 {
for col in c0..=c1 {
let c = CellRef::new(row, col);
if c == a.1 {
continue;
}
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
}
v
})
.collect();
}
}
#[cfg(test)]
{
self.spill_visits += new.len();
}
// Iterating: an array spilled by a formula that read a previous value.
if self.iterate
&& self.spills.iter().any(|(a, arr)| {
@@ -516,21 +615,31 @@ impl Host for PassHost<'_> {
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
let wb = self.wb;
let Some(sh) = wb.sheet(sheet) else { return false };
// Formula cells first, in row-major order like `range_values`: reading one calculates it
// and the pass remembers the value, so the walk below gets the same value again without
// this method holding any.
// Formula and Python cells first, in row-major order like `range_values`: reading one
// calculates it (a Python cell lays out its result) and the pass remembers the value, so
// the walk below gets the same value again without this method holding any.
for (c, cell) in sh.cells.iter_range(range) {
if cell.formula.is_some() {
if cell.formula.is_some() || cell.py.is_some() {
let _ = self.cell_value(sheet, c);
}
}
// Spilled values sit between the stored cells (and the formulas just calculated may have
// produced some): leave such blocks to `range_values`.
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
let mut stored = false;
let _seen = stored_spill_in(sh, range, &mut |_, _| {
stored = true;
false
});
#[cfg(test)]
{
self.spill_visits += _seen;
}
if arrays || stored {
return false;
}
for (c, cell) in sh.cells.iter_range(range) {
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
if !go {
break;
}
+98
View File
@@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() {
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn lifted_lookups_stop_past_their_work_budget() {
// Security: each needle searched the whole lookup vector, so this one cell ran for hours
// (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now.
let mut t = T::new();
t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))");
assert_eq!(t.get("A1"), Value::Error(CellError::Num));
// Ordinary lifted lookups are unaffected.
for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() {
t.set(&format!("B{}", i + 1), &k.to_string());
t.set(&format!("C{}", i + 1), &(i + 1).to_string());
}
t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))");
t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))");
t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))");
t.set("D4", "=SUM(MATCH({25,50},B1:B5))");
assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
@@ -1232,6 +1251,25 @@ fn array_broadcast_result_is_capped() {
assert_eq!(t.num("C3"), 5.0);
}
/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the
/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in
/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at
/// 16384 columns, so the widest product it can build is 16384 squared).
#[test]
fn map_caps_its_result_size() {
let mut t = T::new();
for (at, formula) in
[("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")]
{
t.set(at, formula);
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
}
// Within the cap the broadcast shape is unchanged.
t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0));
}
#[test]
fn text_function() {
let mut t = T::new();
@@ -2274,3 +2312,63 @@ fn loan_schedule_residue_keeps_excels_sign() {
}
assert_eq!(t.get("P7"), Value::Text("".into()));
}
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
use gridcraft_model::python::{PyCode, PyOutput};
let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true };
let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") };
let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) };
t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
(0, c(at))
}
fn column(values: &[f64]) -> Value {
let data = values.iter().map(|v| Value::Number(*v)).collect();
Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap()))
}
#[test]
fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
// B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before
// it in the pass; each must lay it out first rather than read its old value, 1.
let build = |t: &mut T| {
t.set("B2", "10");
t.set("B3", "100");
t.set("A1", "=B1");
t.set("A2", "=MAX(B1:B3)");
t.set("A3", "=SUM(B1:B3)");
t.set("A4", "=COUNTA(B1:B3)");
t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))");
};
let check = |t: &T, how: &str| {
assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}");
assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference");
assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read");
assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell");
assert_eq!(t.num("A4"), 3.0, "{how}");
assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range");
};
// A full recalculation.
let mut t = T::new();
build(&mut t);
plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
t.calc.recalc_all(&mut t.wb);
check(&t, "full");
// An incremental one: B1 becomes a Python cell after its readers.
let mut t = T::new();
t.set("B1", "1");
build(&mut t);
assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0));
let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
t.calc.cells_changed(&mut t.wb, &[k]);
check(&t, "incremental");
// Unblocked, the readers see the new array.
t.set("B2", "");
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
}
+1
View File
@@ -14,6 +14,7 @@ mod frame;
mod kinds;
mod legend;
mod lines;
mod table;
mod trend;
use cartesian::{cartesian, scatter};
+6 -6
View File
@@ -1,6 +1,6 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, plot_kind};
use super::lines::{decimate, draw_markers, draw_runs};
use super::lines::{decimate_area, draw_markers, draw_runs};
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
use super::{
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
@@ -208,7 +208,9 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
// Outer levels padded to the slots (forecast slots stay in the last group).
let outer: Vec<Vec<String>> =
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
let (area, table) = super::table::reserve(ctx, chart, data, area, ncat_slots);
let category_axis = super::table::category_axis(&table, &labels, &outer);
let plot = frame(ctx, area, category_axis, &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
@@ -266,10 +268,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]];
}
}
let top = decimate(top, MAX_LINE_PTS * 2);
let base = decimate(base, MAX_LINE_PTS * 2);
let mut poly = top;
poly.extend(base.into_iter().rev());
let poly = decimate_area(top, base, MAX_LINE_PTS * 2);
if poly.len() >= 3 {
ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None });
}
@@ -355,6 +354,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
}
draw_trend_labels(ctx, plot, &trend_labels);
super::table::draw(ctx, chart, data, plot, table.as_ref());
}
// ---------------------------------------------------------------- scatter & bubble
+13 -4
View File
@@ -74,12 +74,14 @@ pub(super) enum CatAxis<'a> {
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
/// one entry per category, non-empty where a group starts).
Labels(&'a [String], &'a [Vec<String>]),
/// A data table supplies the labels below a vertical chart. Its stub must clear the plot.
Table(&'a [String], f32),
Numeric(Scale, &'a Fmt),
}
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
/// non-empty entry (and at the first category).
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
pub(super) fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
starts.push(n);
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
@@ -101,9 +103,13 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let ls = LABEL_SIZE;
let lh = ls * 1.4;
let ylabels = y.labels();
let stub = match &cat {
CatAxis::Table(_, width) => *width,
_ => 0.0,
};
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
if !horizontal {
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4).max(stub);
let y2labels = y2.map(ValAxis::labels);
let right = match (&y2labels, &cat) {
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
@@ -112,7 +118,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
};
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) => 0.0,
CatAxis::Numeric(..) | CatAxis::Table(..) => 0.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
if !plot.ok() {
@@ -135,6 +141,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Table(..) => {}
CatAxis::Labels(l, outer) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
@@ -181,6 +188,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
} else {
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
CatAxis::Labels(l, o) => (l.to_vec(), o),
CatAxis::Table(l, _) => (l.to_vec(), &[]),
CatAxis::Numeric(..) => (vec![], &[]),
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
@@ -189,7 +197,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
let levels_w: f32 = level_w.iter().sum();
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
let left = (lw + levels_w).max(stub);
let plot = Bx { x: area.x + left, y: area.y + 2.0, w: area.w - left - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
+1 -1
View File
@@ -85,7 +85,7 @@ fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
pub(super) fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
match sw {
Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
+133
View File
@@ -54,6 +54,86 @@ pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
out
}
/// Decimates the top and baseline of an area chart together at the same horizontal indices so
/// `area_mesh`'s paired vertical-strip topology (`pts[i]` ↔ `pts[2n - 1 - i]`) is preserved.
pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
let n = top.len().min(base.len());
if n == 0 {
return Vec::new();
}
if n <= max {
let mut poly = top;
poly.truncate(n);
poly.extend(base.into_iter().take(n).rev());
return poly;
}
let mut cols: Vec<[usize; 6]> = Vec::new();
let mut keep = Vec::with_capacity(max * 2);
let mut i = 0;
while i < n {
let col = top[i][0].floor();
let (start, mut t_mn, mut t_mx, mut b_mn, mut b_mx) = (i, i, i, i, i);
let mut k = i + 1;
while k < n && top[k][0].floor() == col {
if top[k][1] < top[t_mn][1] {
t_mn = k;
}
if top[k][1] > top[t_mx][1] {
t_mx = k;
}
if base[k][1] < base[b_mn][1] {
b_mn = k;
}
if base[k][1] > base[b_mx][1] {
b_mx = k;
}
k += 1;
}
let entry = [start, t_mn, t_mx, b_mn, b_mx, k - 1];
cols.push(entry);
let mut idx = entry;
idx.sort_unstable();
let mut last = usize::MAX;
for ix in idx {
if ix != last {
keep.push(ix);
last = ix;
}
}
i = k;
}
if keep.len() > max * 4 {
let max_groups = (max * 4 / 6).max(1);
let group_size = cols.len().div_ceil(max_groups);
keep.clear();
for ch in cols.chunks(group_size) {
let start = ch[0][0];
let end = ch.last().map_or(start, |c| c[5]);
let t_mn = ch.iter().map(|c| c[1]).min_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start);
let t_mx = ch.iter().map(|c| c[2]).max_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start);
let b_mn = ch.iter().map(|c| c[3]).min_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start);
let b_mx = ch.iter().map(|c| c[4]).max_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start);
let mut idx = [start, t_mn, t_mx, b_mn, b_mx, end];
idx.sort_unstable();
let mut last = usize::MAX;
for ix in idx {
if ix != last {
keep.push(ix);
last = ix;
}
}
}
}
let mut poly = Vec::with_capacity(keep.len() * 2);
for &ix in &keep {
poly.push(top[ix]);
}
for &ix in keep.iter().rev() {
poly.push(base[ix]);
}
poly
}
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
@@ -155,4 +235,57 @@ mod smooth_tests {
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
#[test]
fn decimate_area_preserves_paired_vertical_strips() {
let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect();
let base: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, 100.0]).collect();
let poly = decimate_area(top, base, 64);
assert_eq!(poly.len() % 2, 0);
let half = poly.len() / 2;
for i in 0..half {
assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]);
assert_eq!(poly[poly.len() - 1 - i][1], 100.0);
}
}
#[test]
fn decimate_area_preserves_extrema_across_3000_columns() {
// 3,000 occupied pixel columns with 6 points per column (18,000 points total),
// where each column has distinct top and baseline extrema so the initial per-column
// index list exceeds `MAX_LINE_PTS * 2 * 4` (6,400).
let max = MAX_LINE_PTS * 2;
let mut top = Vec::with_capacity(18_000);
let mut base = Vec::with_capacity(18_000);
for col in 0..3_000 {
let y_top = [50.0, 20.0, 80.0, 50.0, 50.0, 50.0];
let y_base = [150.0, 150.0, 150.0, 120.0, 180.0, 150.0];
for s in 0..6 {
let x = col as f32 + s as f32 * 0.1;
top.push([x, y_top[s]]);
base.push([x, y_base[s]]);
}
}
// Plant sharp spikes on both top and baseline in interior columns.
top[777 * 6 + 1][1] = 1.0;
top[1999 * 6 + 2][1] = 99.0;
base[1234 * 6 + 3][1] = 101.0;
base[2501 * 6 + 4][1] = 199.0;
let poly = decimate_area(top, base, max);
assert_eq!(poly.len() % 2, 0);
let half = poly.len() / 2;
assert!(half <= max * 4, "retained {half} paired vertices, limit is {}", max * 4);
for i in 0..half {
assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]);
}
let top_kept = &poly[..half];
let base_kept = &poly[half..];
assert_eq!(top_kept.first().map(|p| p[0]), Some(0.0));
assert_eq!(top_kept.last().map(|p| p[0]), Some(2999.5));
assert!(top_kept.iter().any(|p| p[1] == 1.0), "top min spike preserved");
assert!(top_kept.iter().any(|p| p[1] == 99.0), "top max spike preserved");
assert!(base_kept.iter().any(|p| p[1] == 101.0), "base min spike preserved");
assert!(base_kept.iter().any(|p| p[1] == 199.0), "base max spike preserved");
}
}
+107
View File
@@ -0,0 +1,107 @@
//! The chart data table uses the same primitives as the plot, including in print/PDF output.
use super::frame::{Bx, CatAxis, level_groups};
use super::legend::{Swatch, cat_label, draw_swatch, plot_kind};
use super::{AXIS, Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, TEXT, VAlign};
pub(super) struct Layout {
top: f32,
row_height: f32,
headers: usize,
stub: f32,
columns: usize,
}
pub(super) fn category_axis<'a>(layout: &Option<Layout>, labels: &'a [String], outer: &'a [Vec<String>]) -> CatAxis<'a> {
match layout {
Some(t) => CatAxis::Table(labels, t.stub),
None => CatAxis::Labels(labels, outer),
}
}
/// Reserve at most half the available height. A table that cannot fit is explicitly labelled
/// instead of dropping rows or allocating a series × point-sized matrix.
pub(super) fn reserve(ctx: &mut Ctx, chart: &Chart, data: &ChartData, mut area: Bx, columns: usize) -> (Bx, Option<Layout>) {
if chart.data_table.is_none() || !chart.supports_data_table() {
return (area, None);
}
let headers = data.category_levels.len() + 1;
let rows = headers + data.series.len();
let row_height = LABEL_SIZE * 1.4 + 4.0;
let height = row_height * rows as f32;
let key_width = if chart.data_table.as_ref().is_some_and(|t| t.show_keys) { 22.0 } else { 0.0 };
let names = data.series.iter().take(32).map(|s| ctx.tw(&s.name, LABEL_SIZE)).fold(0.0f32, f32::max);
let stub = (names + key_width + 10.0).clamp(24.0, (area.w * 0.3).max(24.0));
if columns == 0 || columns > 128 || data.series.len() > 32 || height > area.h * 0.5 || (area.w * 0.6) / (columns as f32) < 18.0 {
let message = ctx.fit("Data table needs more space", LABEL_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &message, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
area.h -= row_height;
return (area, None);
}
area.h -= height;
(area, Some(Layout { top: area.bottom(), row_height, headers, stub, columns }))
}
pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, layout: Option<&Layout>) {
let Some(layout) = layout else { return };
let Some(settings) = &chart.data_table else { return };
let x0 = plot.x - layout.stub;
let top = layout.top;
let rows = layout.headers + data.series.len();
let bottom = top + layout.row_height * rows as f32;
let cw = plot.w / layout.columns as f32;
// Outermost category level first, then the individual categories.
for (i, level) in data.category_levels.iter().rev().enumerate() {
for (first, end) in level_groups(level, layout.columns) {
let text = level.get(first).map(String::as_str).unwrap_or("");
let text = ctx.fit(text, LABEL_SIZE, (end - first) as f32 * cw - 4.0);
let x = plot.x + (first + end) as f32 * cw / 2.0;
ctx.text(x, top + (i as f32 + 0.5) * layout.row_height, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
}
}
let header_y = top + (layout.headers as f32 - 0.5) * layout.row_height;
let n = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
for j in 0..layout.columns {
let text = if j < n { cat_label(data, j) } else { String::new() };
let text = ctx.fit(&text, LABEL_SIZE, cw - 4.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, header_y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
}
for (i, series) in data.series.iter().enumerate() {
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
if settings.show_keys {
let swatch = match plot_kind(chart, series) {
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
_ => Swatch::Box,
};
draw_swatch(ctx, swatch, x0 + 4.0, y, 7.0, series.color);
}
let name = ctx.fit(&series.name, LABEL_SIZE, plot.x - name_x - 4.0);
ctx.text(name_x, y, &name, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
let fmt = Fmt::new(&series.number_format);
for j in 0..layout.columns {
let value = series.values.get(j).copied().flatten().map(|v| fmt.format(v, None)).unwrap_or_default();
let text = ctx.fit(&value, LABEL_SIZE, cw - 4.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
}
}
if settings.horizontal_border {
for row in 1..rows {
ctx.hline(x0, plot.right(), top + row as f32 * layout.row_height, AXIS, 0.75);
}
}
if settings.vertical_border {
ctx.vline(plot.x, top, bottom, AXIS, 0.75);
// The outer headers span groups; category dividers begin at the innermost header.
for j in 1..layout.columns {
ctx.vline(plot.x + j as f32 * cw, top + (layout.headers - 1) as f32 * layout.row_height, bottom, AXIS, 0.75);
}
}
if settings.outline {
ctx.hline(x0, plot.right(), top, AXIS, 0.75);
ctx.hline(x0, plot.right(), bottom, AXIS, 0.75);
ctx.vline(x0, top, bottom, AXIS, 0.75);
ctx.vline(plot.right(), top, bottom, AXIS, 0.75);
}
}
+3
View File
@@ -5,6 +5,8 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
mod data_table;
const ALL_KINDS: [ChartKind; 26] = [
ChartKind::ColumnClustered,
ChartKind::ColumnStacked,
@@ -43,6 +45,7 @@ fn chart(kind: ChartKind) -> Chart {
series: vec![],
legend: LegendPos::Bottom,
data_labels: true,
data_table: None,
gridlines: true,
style: 0,
x_title: Some("Quarter".into()),
+121
View File
@@ -0,0 +1,121 @@
use super::*;
use gridcraft_model::ChartDataTable;
#[test]
fn tables_draw_source_values_for_every_supported_chart_kind() {
for kind in ALL_KINDS {
let mut c = chart(kind);
c.legend = LegendPos::None;
c.data_labels = false;
c.data_table = Some(ChartDataTable::default());
let supported = c.supports_data_table();
let d = sample(kind);
let prims = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
assert_sane(&prims, 900.0, 500.0);
let labels = texts(&prims);
// Source amounts, including on 100% stacked charts, not shares or running totals.
if supported {
for value in ["$12,000", "-$4,000", "$38,000", "$61,870"] {
assert!(labels.contains(&value), "{kind:?}: missing {value}");
}
} else {
c.data_table = None;
assert_eq!(prims, render(&c, &d, 900.0, 500.0, &ApproxMeasure));
}
}
}
#[test]
fn table_columns_align_with_plot_and_keep_missing_values_blank() {
let mut c = chart(ChartKind::ColumnClustered);
c.title = Some(String::new());
c.x_title = None;
c.y_title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.data_table = Some(ChartDataTable::default());
let color = [1, 2, 3, 255];
let mut s = series("Rate", &[0.25, 0.0, 0.75], color, c.kind);
s.values[1] = None;
s.number_format = "0.0%".into();
let d = ChartData { categories: ["Jan", "Feb", "Mar"].map(String::from).to_vec(), series: vec![s], ..Default::default() };
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
let at = |text: &str| -> (f32, f32) {
p.iter()
.find_map(|p| match p {
Prim::Text { text: t, x, y, .. } if t == text => Some((*x, *y)),
_ => None,
})
.unwrap()
};
let (x1, y1) = at("25.0%");
let (x3, y3) = at("75.0%");
let header_y = at("Jan").1;
assert!(y1 > header_y && (y1 - y3).abs() < 0.01);
assert_eq!(at("Jan").0, x1);
assert_eq!(at("Mar").0, x3);
assert_eq!(texts(&p).iter().filter(|t| **t == "Jan").count(), 1, "the table replaces category-axis labels");
let centers: Vec<f32> = p
.iter()
.filter_map(|p| match p {
Prim::Rect { x, y, w, fill: Some(f), .. } if *f == color && *y < header_y => Some(x + w / 2.0),
_ => None,
})
.collect();
assert_eq!(centers.len(), 2);
assert!((centers[0] - x1).abs() < 0.01 && (centers[1] - x3).abs() < 0.01);
let cells: Vec<&str> = p
.iter()
.filter_map(|p| match p {
Prim::Text { y, text, .. } if (*y - y1).abs() < 0.01 => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(cells, ["Rate", "25.0%", "75.0%"], "blank cells are not zeroes");
assert!(
p.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)),
"legend key independent of hidden chart legend"
);
c.data_table = Some(ChartDataTable { show_keys: false, ..Default::default() });
let off = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
assert!(!off.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)));
}
#[test]
fn table_borders_are_independent_and_multilevel_headers_are_kept() {
let mut c = chart(ChartKind::Line);
c.legend = LegendPos::None;
c.data_labels = false;
let mut d = sample(c.kind);
d.category_levels = vec![vec!["2025".into(), "".into(), "".into(), "2026".into(), "".into(), "".into()]];
for (h, v, outline, want) in [(false, false, false, 0), (true, false, false, 4), (false, true, false, 6), (false, false, true, 4)] {
c.data_table = Some(ChartDataTable { horizontal_border: h, vertical_border: v, outline, show_keys: false });
let p = render(&c, &d, 900.0, 600.0, &ApproxMeasure);
let year_y = p
.iter()
.find_map(|p| match p {
Prim::Text { text, y, .. } if text == "2025" => Some(*y),
_ => None,
})
.unwrap();
assert!(texts(&p).contains(&"2026"));
let lines = p.iter().filter(|p| matches!(p, Prim::Line { pts, .. } if pts.iter().all(|p| p[1] > year_y - 9.0))).count();
assert_eq!(lines, want, "{h} {v} {outline}");
assert_sane(&p, 900.0, 600.0);
}
}
#[test]
fn dense_and_tiny_tables_are_bounded_and_explicit() {
let mut c = chart(ChartKind::ColumnClustered);
c.data_table = Some(ChartDataTable::default());
let mut d = sample(c.kind);
d.categories = (0..20_000).map(|i| i.to_string()).collect();
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
assert!(texts(&p).contains(&"Data table needs more space"));
assert_sane(&p, 600.0, 400.0);
d.categories.clear();
for (w, h) in [(1.0, 1.0), (10.0, 10.0), (40.0, 35.0), (200.0, 100.0)] {
assert_sane(&render(&c, &d, w, h, &ApproxMeasure), w, h);
}
}
+16
View File
@@ -188,6 +188,12 @@ impl RangeRef {
end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)),
}
}
/// The top-left cell of a list of areas: the lowest row and the lowest column across all of
/// them, whatever order they are listed in. Excel writes the relative formulas of a
/// multi-area validation or conditional format for this cell. `None` for no areas.
pub fn top_left(ranges: &[RangeRef]) -> Option<CellRef> {
ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start)
}
/// Row-major iterator over the cells. Callers must bound huge ranges themselves.
pub fn iter(&self) -> impl Iterator<Item = CellRef> + '_ {
let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col);
@@ -307,6 +313,16 @@ mod tests {
Ok(())
}
#[test]
fn top_left_spans_every_area_in_any_order() {
let areas: Vec<RangeRef> = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect();
assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26"));
// Lowest row and lowest column, even when they come from different areas.
let areas: Vec<RangeRef> = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect();
assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2"));
assert_eq!(RangeRef::top_left(&[]), None);
}
#[test]
fn sheet_quoting_follows_excel() {
for quoted in [
+25 -2
View File
@@ -21,10 +21,18 @@ pub enum CellError {
Calc,
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
Circ,
/// A Python cell's code raised an error, or Python stopped.
Python,
/// A Python cell is waiting to run.
Busy,
/// A Python cell ran longer than the time limit.
Timeout,
/// Python can't run here: the workbook's content isn't enabled, or this build has no Python.
Blocked,
}
impl CellError {
pub const ALL: [CellError; 10] = [
pub const ALL: [CellError; 14] = [
CellError::Null,
CellError::Div0,
CellError::Value,
@@ -35,6 +43,10 @@ impl CellError {
CellError::GettingData,
CellError::Spill,
CellError::Calc,
CellError::Python,
CellError::Busy,
CellError::Timeout,
CellError::Blocked,
];
pub fn as_str(&self) -> &'static str {
match self {
@@ -49,9 +61,16 @@ impl CellError {
CellError::Spill => "#SPILL!",
CellError::Calc => "#CALC!",
CellError::Circ => "#CIRC!",
CellError::Python => "#PYTHON!",
CellError::Busy => "#BUSY!",
CellError::Timeout => "#TIMEOUT!",
CellError::Blocked => "#BLOCKED!",
}
}
/// `ERROR.TYPE` number.
/// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!)
/// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or
/// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use,
/// and may change if Excel's numbers become known.
pub fn code(&self) -> u32 {
match self {
CellError::Null => 1,
@@ -65,6 +84,10 @@ impl CellError {
CellError::Spill => 9,
CellError::Calc => 14,
CellError::Circ => 15,
CellError::Blocked => 11,
CellError::Python => 19,
CellError::Busy => 20,
CellError::Timeout => 21,
}
}
pub fn parse(s: &str) -> Option<CellError> {
+1
View File
@@ -23,6 +23,7 @@ log = { workspace = true }
gridcraft-xlsx = { workspace = true }
gridcraft-chart = { workspace = true }
gridcraft-pdf = { workspace = true }
gridcraft-math = { workspace = true }
image = { workspace = true }
[dev-dependencies]
+1
View File
@@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("view.unhideWindow", "View", "Window", "Unhide"),
("view.switchWindows", "View", "Window", "Switch Windows"),
("view.macros", "View", "Macros", "Macros"),
("macro.record", "View", "Macros", "Record Macro"),
// Automate
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
+1 -1
View File
@@ -244,7 +244,7 @@ impl CfCache {
let v = sh.value(c);
let no_stats = RuleStats::default();
for (idx, cf) in rules {
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
let first = RangeRef::top_left(&cf.ranges).unwrap_or(c);
let st: &RuleStats = match stats_need(&cf.rule) {
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
None => &no_stats,
+75 -9
View File
@@ -5,6 +5,7 @@ pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
pub mod equation;
pub mod extra;
pub mod file;
pub mod forecast;
@@ -12,8 +13,10 @@ pub mod format;
pub mod formulas;
pub mod insert;
pub mod inspect;
pub mod macros;
pub mod pivot;
pub mod print;
pub mod python;
pub mod review;
pub mod sheet;
pub mod slicer;
@@ -39,7 +42,11 @@ pub struct CommandSpec {
pub label: &'static str,
/// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
pub menu: &'static [&'static str],
/// Portable notation: `Cmd` is ⌘ on macOS and Ctrl elsewhere.
pub shortcut: Option<&'static str>,
/// macOS override when its key differs. Equal to `shortcut` marks a Mac-only shortcut
/// (Align Left/Center): other platforms show none. See [`CommandInfo::shortcut_for_platform`].
pub shortcut_mac: Option<&'static str>,
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
@@ -53,12 +60,32 @@ pub struct CommandInfo {
pub label: &'static str,
pub menu: Vec<&'static str>,
pub shortcut: Option<&'static str>,
pub shortcut_mac: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
impl CommandInfo {
/// Format the portable shortcut notation for the runtime operating system.
pub fn shortcut_for_platform(&self, mac: bool) -> Option<String> {
let keys = if mac {
self.shortcut_mac.or(self.shortcut)
} else if self.shortcut_mac.is_some() && self.shortcut_mac == self.shortcut {
// An identical Mac override marks a Mac-only display shortcut.
// The portable field remains unchanged for legacy CLI consumers.
None
} else {
self.shortcut
}?;
if mac {
return Some(keys.to_owned());
}
Some(keys.split('+').map(|part| if part == "Cmd" { "Ctrl" } else { part }).collect::<Vec<_>>().join("+"))
}
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
@@ -67,6 +94,7 @@ impl CommandSpec {
label: self.label,
menu: self.menu.to_vec(),
shortcut: self.shortcut,
shortcut_mac: self.shortcut_mac,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
@@ -117,13 +145,13 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
};
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
};
}
pub(crate) use cmd;
@@ -137,16 +165,19 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(format::specs());
v.extend(sheet::specs());
v.extend(insert::specs());
v.extend(equation::specs());
v.extend(data::specs());
v.extend(formulas::specs());
v.extend(review::specs());
v.extend(view::specs());
v.extend(inspect::specs());
v.extend(extra::specs());
v.extend(macros::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(python::specs());
v.extend(slicer::specs());
v.extend(whatif::specs());
v.extend(forecast::specs());
@@ -178,6 +209,7 @@ pub fn register_test_command(id: &'static str, run: Run) {
label: id,
menu: &[],
shortcut: None,
shortcut_mac: None,
params: "",
enabled: |_| Ok(()),
run,
@@ -195,6 +227,7 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
label: id,
menu: &[],
shortcut: None,
shortcut_mac: None,
params: "",
enabled: |_| Ok(()),
run,
@@ -337,6 +370,9 @@ pub struct Ctx<'a> {
/// The command applied sheet protection itself (row/column inserts and deletes the
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
pub protection_checked: bool,
/// The structural edits the command made, in order (the sheet's name, the edit), as
/// references were rewritten for them.
pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>,
}
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
@@ -358,15 +394,36 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let mut ctx = Ctx {
wb: (*d.wb).clone(),
changed: Vec::new(),
structural: false,
sel: &mut sel,
fit_rows: Vec::new(),
protection_checked: false,
edits: Vec::new(),
};
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
// Equations whose source changed (or that arrived without a layout, as from a paste) are
// laid out here, so painting only ever reads the cache.
wb.refresh_equations();
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if !structural {
check_arrays(&d.wb, &wb)?;
}
if let Some(log) = d.touched.as_mut() {
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
if let Some(sh) = wb.sheet(*si) {
log.push((sh.name.clone(), *c));
}
}
}
if let Some(log) = d.edits.as_mut() {
log.extend(edits);
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
@@ -394,6 +451,10 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
Ok(r)
}
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
/// that kept a cell's value, so a shift is refused then.
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
/// Excel's message for an edit of a locked cell on a protected sheet.
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
@@ -463,7 +524,7 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
continue;
}
for c in sh.cells.diff(&after.cells) {
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
let filled = after.cell(c).is_some_and(gridcraft_model::Cell::has_content);
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
continue;
}
@@ -479,13 +540,18 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
Ok(())
}
/// Two cells hold the same value or formula (formats aside).
/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result
/// is not something the person edits).
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| !c.has_content());
match (a, b) {
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
(None, None) => a.value == b.value,
(None, None) => match (&a.py, &b.py) {
(Some(x), Some(y)) => x.code == y.code && x.object == y.object,
(None, None) => a.value == b.value,
_ => false,
},
_ => false,
},
_ => empty(a) && empty(b),
File diff suppressed because it is too large Load Diff
+24 -12
View File
@@ -99,18 +99,30 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
cmd!(
"data.group",
"Group",
["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
has_doc,
|s, p| group(s, p, true)
),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
group(s, p, false)
}),
CommandSpec {
shortcut_mac: Some("Cmd+Shift+K"),
..cmd!(
"data.group",
"Group",
["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
has_doc,
|s, p| group(s, p, true)
)
},
CommandSpec {
shortcut_mac: Some("Cmd+Shift+J"),
..cmd!(
"data.ungroup",
"Ungroup",
["Data", "Outline"],
Some("Alt+Shift+Left"),
"{rows? | cols? | by?: rows|cols, sheet?}",
has_doc,
|s, p| { group(s, p, false) }
)
},
cmd!(
"data.hideDetail",
"Hide Detail",
+20 -27
View File
@@ -195,8 +195,7 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
// can be re-fitted to the lines it spans after the sort.
let objs: Vec<Anchor> =
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
let objs: Vec<Anchor> = sh.object_anchors().copied().collect();
let descs: Vec<(u32, u32, f32, f32)> = objs
.iter()
.map(|a| {
@@ -242,33 +241,22 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
}
}
}
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
// Re-anchor floating objects now that the row heights are final. `objs` was collected in
// `object_anchors` order, which `object_anchors_mut` repeats.
// Only objects anchored inside the sorted block move: within its lines on the sort axis
// and within its span on the other axis (a picture beside the block stays put).
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
let ni = shm.images.len();
let nch = shm.charts.len();
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 {
continue;
}
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
if na != a {
if i < ni {
if let Some(o) = shm.images.get_mut(i) {
o.anchor = na;
}
} else if i < ni + nch {
if let Some(o) = shm.charts.get_mut(i - ni) {
o.anchor = na;
}
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
o.anchor = na;
}
}
let new_anchors: Vec<Anchor> = objs
.into_iter()
.zip(descs)
.map(|(a, desc)| {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 { a } else { sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of) }
})
.collect();
for (a, n) in shm.object_anchors_mut().zip(new_anchors) {
*a = n;
}
for (c, _) in moved {
cx.changed.push((sheet, c));
@@ -284,7 +272,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body, and
/// keeps its size when the sort turns its lines around (its bottom line now sorts before its top
/// line), since the lines then enclose nothing to stretch it between.
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
@@ -302,7 +292,10 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
let y1 = sh.row_top(nr) + a.dy as f64;
let x2 = sh.col_left(nbc) + tdx as f64;
let y2 = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
// An extent that is not positive means the sort put the bottom-right line before the
// top-left one on that axis: the object keeps its old size there.
let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) };
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode }
} else {
Anchor { cell: CellRef::new(nr, nc), ..a }
}
+39
View File
@@ -480,6 +480,45 @@ fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
}
#[test]
fn a_relative_list_source_is_read_from_the_cell_being_checked() {
// As in Excel, `=$B1:$B2` on A3:A4,A1:A2 is written for A1 (the top-left cell of all the
// areas) and lists B4:B5 at A4. It used to list B1:B2 in every cell.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B1:B5", "values": [["a"], ["b"], ["c"], ["d"], ["e"]]})).unwrap();
s.execute("data.validation", json!({"range": "A3:A4,A1:A2", "type": "list", "formula1": "=$B1:$B2"})).unwrap();
let wb = s.doc().unwrap().wb.clone();
let dv = wb.active().unwrap().validations[0].clone();
for (cell, items) in [("A1", ["a", "b"]), ("A2", ["b", "c"]), ("A3", ["c", "d"]), ("A4", ["d", "e"])] {
let at = CellRef::parse(cell).unwrap();
assert_eq!(list_items(&wb, 0, at, &dv), items, "{cell}");
for v in ["a", "b", "c", "d", "e"] {
assert_eq!(check_validation(&wb, 0, at, v).is_none(), items.contains(&v), "{cell} with {v}");
}
}
}
#[test]
fn a_list_source_that_takes_in_the_cell_does_not_list_the_value_being_typed() {
// `=$A1:$A2` on A2:A3 is A1:A2 at A2 and A2:A3 at A3. The list is the one before the entry, as
// in Excel; a new value used to land in its own source and pass.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["x"], ["y"]]})).unwrap();
s.execute("data.validation", json!({"range": "A2:A3", "type": "list", "formula1": "=$A1:$A2"})).unwrap();
let wb = &s.doc().unwrap().wb;
for (cell, accepted, refused) in [("A2", &["x", "y"][..], &["new"][..]), ("A3", &["y"], &["x", "new"])] {
let at = CellRef::parse(cell).unwrap();
for v in accepted {
assert!(check_validation(wb, 0, at, v).is_none(), "{cell} refuses {v}");
}
for v in refused {
assert!(check_validation(wb, 0, at, v).is_some(), "{cell} accepts {v}");
}
}
}
#[test]
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
+10 -1
View File
@@ -146,7 +146,16 @@ pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
}
}
for (c, t) in out {
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
let cell = match crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb) {
Ok(cell) => cell,
// `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept
// as text rather than lost.
Err(_) if gridcraft_model::python::is_py_formula(&t) => {
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
Some(Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style, py: None })
}
Err(_) => None,
};
let shm = cx.sheet_mut(sheet)?;
match cell {
Some(x) => shm.set_cell(c, x),
+19 -7
View File
@@ -38,11 +38,18 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
/// When the rule's top-left cell goes, its relative formulas are rewritten for the new top-left
/// cell, so the cells left keep checking the same cells.
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
let before = RangeRef::top_left(&dv.ranges);
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
}
if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) {
dv.f1 = move_rule_formula(&dv.f1, from, to);
dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to));
}
}
validations.retain(|d| !d.ranges.is_empty());
}
@@ -145,8 +152,10 @@ fn list_source(f: &str) -> String {
format!("\"{}\"", t.replace('"', "\"\""))
}
/// List entries of a list validation (literal or from a range).
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
/// List entries of a list validation (literal or from a range) for the cell `at`. A relative
/// source is written for the rule's top-left cell and read from `at`, as in Excel (`=$B1:$B3` on
/// A1:A9 lists B5:B7 at A5).
pub fn list_items(wb: &Workbook, sheet: usize, at: CellRef, dv: &Validation) -> Vec<String> {
let f = dv.f1.trim();
if is_literal_list(f) {
// A quoted list is a string literal: `""` inside stands for one `"`.
@@ -156,8 +165,8 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
};
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
match gridcraft_calc::evaluate(wb, sheet, at, f) {
let first = RangeRef::top_left(&dv.ranges).unwrap_or(at);
match gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)) {
Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(),
v if !v.is_empty() && !v.is_error() => vec![v.display()],
_ => vec![],
@@ -175,9 +184,10 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
return None;
}
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
// The formulas are written for the first cell of the range and see the value being entered,
// The formulas are written for the top-left cell of all the areas and see the value being entered,
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
let first = RangeRef::top_left(&dv.ranges).unwrap_or(at);
let original = wb;
let entered;
let wb = if sh.value(at) == v {
wb
@@ -193,7 +203,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
let num = |f: &str| eval(f).to_number().ok();
let ok = match dv.kind {
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
// The list is the one the dropdown showed before the entry, as in Excel: a source that takes
// in the cell itself (`=$A1:$A2` on A2:A3 is A2:A3 at A3) doesn't list the value being typed.
ValidationKind::List => list_items(original, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
ValidationKind::TextLength => {
let n = input.chars().count() as f64;
+1
View File
@@ -80,6 +80,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
let id = d.wb.next_object_id();
let shape = Shape {
math: Default::default(),
id,
kind: ShapeKind::Ink,
anchor: Anchor {
+41 -19
View File
@@ -20,7 +20,7 @@ mod paste_special;
mod selection;
use cell::{cell_set, range_fill, range_set_values};
pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula};
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
#[cfg(test)]
pub(crate) use clipboard::parse_tsv;
use clipboard::{clear_clipboard, copy, cut, paste};
@@ -35,7 +35,7 @@ use go_to::go_to_special;
#[cfg(test)]
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
use paste_special::paste_special;
pub(crate) use paste_special::rewrite_all_formulas;
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
pub fn specs() -> Vec<CommandSpec> {
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
has_doc,
range_set_values
),
@@ -81,15 +81,18 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
paste
),
cmd!(
"edit.pasteSpecial",
"Paste Special…",
["Home", "Clipboard"],
Some("Ctrl+Cmd+V"),
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
has_clipboard,
paste_special
),
CommandSpec {
shortcut_mac: Some("Ctrl+Cmd+V"),
..cmd!(
"edit.pasteSpecial",
"Paste Special…",
["Home", "Clipboard"],
Some("Ctrl+Alt+V"),
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
has_clipboard,
paste_special
)
},
cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
@@ -129,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace…",
["Home", "Editing", "Find & Select"],
Some("Ctrl+H"),
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
has_doc,
replace
),
@@ -183,6 +186,8 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
// Step by step: a rename and a new sheet undone together match up only one step apart.
super::extra::keep_view_modes(d, &cur_wb);
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
@@ -203,6 +208,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
super::extra::keep_view_modes(d, &cur_wb);
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
@@ -213,6 +219,13 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- clear
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
}
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
/// are left as they are; rows hidden by hand are cleared.
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
@@ -223,7 +236,10 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
let skip = filtered_rows(sh, r);
sh.cells
.iter_range(r)
.any(|(c, x)| !skip.contains(&c.row) && x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
@@ -237,8 +253,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
},
None => continue,
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
.unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
@@ -247,6 +267,7 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
for (_, cell) in cells.iter_mut() {
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
cell.formula = None;
cell.py = None;
if checkbox && uncheck_only {
cell.value = Value::Bool(false);
} else {
@@ -273,16 +294,17 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
_ => {}
}
}
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !r.contains(*c));
sh.comments.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
sh.hyperlinks.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "formats") {
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m);
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
unmerged |= !keep;
keep
});
+84 -18
View File
@@ -5,24 +5,36 @@ use super::{
// ---------------------------------------------------------------- entry
/// Typing `=PY(` doesn't make a Python cell; this says what does.
pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…).";
/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or
/// a parenthesis (not a number), as Excel takes `+SUM(1)`.
pub(crate) fn is_formula_input(input: &str) -> bool {
input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('))
}
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
/// format). Inherits the destination's cell, row or column style, including its number
/// format, before parsing. Errors when the formula can't be parsed.
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
if input.is_empty() {
let c = Cell { value: Value::Empty, formula: None, style };
let c = Cell { value: Value::Empty, formula: None, style, py: None };
return Ok(if c.is_blank() { None } else { Some(c) });
}
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
if fmt_is_text {
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None }));
}
let is_formula = is_formula_input(input);
if is_formula && gridcraft_model::python::is_py_formula(input) {
return Err(EngineError::Other(PY_FORMULA.into()));
}
let is_formula = input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
let body = input.strip_prefix('=').unwrap_or(input);
let expr = wb.name_call_case(parse_typed_formula(body)?);
@@ -74,7 +86,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
if input.contains('\n') && !wb.styles.get(style).align.wrap {
style = wb.styles.derive(style, |s| s.align.wrap = true);
}
Ok(Some(Cell { value: parsed.value, formula: None, style }))
Ok(Some(Cell { value: parsed.value, formula: None, style, py: None }))
}
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
@@ -142,7 +154,7 @@ pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(sheet)?;
// The array takes the whole range: other cells in it lose their contents.
for c in range.iter().skip(1).take(1_000_000) {
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) {
let style = old.style;
sh.set_cell(c, Cell { style, ..Cell::default() });
}
@@ -183,6 +195,22 @@ pub(crate) fn json_to_input(v: &Json) -> String {
}
}
/// How many converted strings `range.setValues` reports back, and how much of each input and
/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data
/// in the result.
const MAX_REPORTED_CONVERSIONS: usize = 20;
const MAX_REPORTED_CHARS: usize = 32;
/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was
/// cut. Counting characters keeps a multi-byte input whole.
fn clipped(text: &str) -> String {
let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect();
if out.len() < text.len() {
out.push('…');
}
out
}
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
@@ -200,6 +228,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("range.setValues", "too many rows"));
}
let mut count = 0;
// The strings typed input read as something other than the text given, for the result: a
// caller can notice a "05.04.2026" that became a date without reading the file back (#2255).
let mut converted: Vec<(CellRef, String)> = Vec::new();
let mut converted_count = 0;
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
let cols: Vec<&Json> = match row.as_array() {
@@ -214,23 +246,47 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
value: Value::number(n.as_f64().unwrap_or(0.0)),
formula: None,
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
py: None,
}),
Json::Bool(b) => Some(Cell {
value: Value::Bool(*b),
formula: None,
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
py: None,
}),
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
if text.is_empty() {
// An empty string clears the cell, as `null` and the default path do:
// an empty text cell is not blank, and a script clearing cells with
// `""` would leave cells `COUNTA` counts. The cell keeps its formatting.
input_to_cell("", sheet, at, &mut cx.wb)?
} else {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
// A string that became a formula was written as one (`=`), which is deliberate;
// only a number (dates and times included), boolean or error is a reading of the
// string and worth reporting.
if let Some(c) = &cell
&& matches!(v, Json::String(_))
&& c.formula.is_none()
&& matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_))
{
converted_count += 1;
if converted.len() < MAX_REPORTED_CONVERSIONS {
converted.push((at, input.clone()));
}
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
@@ -245,7 +301,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
}
Ok(())
})?;
Ok(json!({"cells": count}))
let d = s.doc()?;
let reported: Vec<Json> =
d.wb.sheet(sheet)
.map(|sh| {
converted
.iter()
.map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))}))
.collect()
})
.unwrap_or_default();
Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported}))
}
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
+1 -1
View File
@@ -92,7 +92,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
for c in cells {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
+28
View File
@@ -115,6 +115,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let all = bool_param(p, "all").unwrap_or(true);
let workbook = str_param(p, "within") == Some("workbook");
let active = s.doc()?.selection.active;
let mut skipped = 0usize;
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
let mut n = 0;
@@ -135,6 +136,30 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
} else {
replace_ci(&text, &what, &with)
};
// A Python cell's text is its code: the replacement edits the code and the cell
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
if let Some(old) = old_py {
let py = gridcraft_model::PyCode { object: old.object, ..gridcraft_model::PyCode::new(&new) };
if py.code == old.code {
continue;
}
if !py.fits() {
skipped += 1;
continue;
}
// Its old spill is left to the recalculation, which removes it and
// recalculates what read it.
let sh = cx.sheet_mut(si)?;
let style = sh.style_id(c);
sh.set_cell(
c,
Cell { value: Value::Error(gridcraft_core::CellError::Blocked), formula: None, style, py: Some(std::sync::Arc::new(py)) },
);
cx.touch(si, c);
n += 1;
continue;
}
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
let sh = cx.sheet_mut(si)?;
match cell {
@@ -149,6 +174,9 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
}
Ok(n)
})?;
if skipped > 0 {
return Ok(json!({"replaced": n, "skipped": skipped}));
}
Ok(json!({"replaced": n}))
}
+2 -2
View File
@@ -174,8 +174,8 @@ pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
let v = sheet.value(c);
let ok = match kind {
"blanks" => v.is_empty(),
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
"constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()),
"formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()),
"comments" | "notes" => sheet.comments.contains_key(&c),
"errors" => v.is_error(),
"numbers" => v.is_number(),
+146 -5
View File
@@ -145,6 +145,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
moved_formulas = taken;
}
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
@@ -152,7 +153,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
@@ -186,6 +187,33 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
// cells its `xl()` references named on its original sheet, as a moved formula does:
// they are qualified with that sheet before the workbook-wide move adjustment below.
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
let Some(py) = cell.py.clone() else { return Ok(None) };
if !cut_move || dest_sheet == clip.sheet {
return Ok(Some(py));
}
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
});
Some(gridcraft_formula::print(&e))
});
Ok(Some(match qualified {
Some(code) => {
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
check_python_fits(&moved, &dest_name, dest)?;
std::sync::Arc::new(moved)
}
None => py,
}))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
@@ -194,6 +222,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new.py = None;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
@@ -202,6 +231,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
new.py = moved_py(&sc_cell)?;
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
@@ -214,6 +244,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
new.py = moved_py(&sc_cell)?;
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
@@ -222,6 +253,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.py = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
@@ -232,7 +264,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
&& new.py.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
@@ -248,7 +284,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
@@ -292,7 +328,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
} else {
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
cx.structural = true;
}
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
@@ -316,7 +352,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> {
use gridcraft_formula::adjust::Edit;
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
// (a cut moves its arrays itself).
@@ -355,4 +391,109 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
n.formula = gridcraft_formula::print(&new);
}
}
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
}
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
/// name and bubble sizes, and the source block it was made from). Every edit that moves
/// references calls it: inserting and deleting lines and cells and cut-moves (through
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
/// block that no longer exists is dropped.
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
for si in 0..wb.sheets.len() {
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(sh) = wb.sheet_mut(si) else { continue };
let fix = |text: &str| -> String {
match gridcraft_formula::parse(text) {
Ok(expr) => gridcraft_formula::print(&f(&host, expr)),
Err(_) => text.to_string(),
}
};
for ch in sh.charts.iter_mut() {
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
}
}
}
}
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
/// when the reference it names changed, and text that doesn't parse is kept. A cell's cached
/// result is kept while its references still name the same data (a rename, a move); a reference
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
/// recalculation removes its spill. A rewrite that would
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
/// the whole edit, which then changes nothing: see [`check_python_fits`].
pub(crate) fn rewrite_python_refs(
wb: &mut gridcraft_model::Workbook,
f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr,
) -> Result<()> {
for si in 0..wb.sheets.len() {
let Some(sh) = wb.sheets.get(si) else { continue };
let host = sh.name.clone();
let mut new_code: Vec<(CellRef, String, bool)> = Vec::new();
for (c, cell) in sh.cells.iter() {
let Some(py) = &cell.py else { continue };
let mut lost = false;
let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
let expr = gridcraft_formula::parse(reference).ok()?;
let before = gridcraft_formula::print(&expr);
let after = gridcraft_formula::print(&f(&host, expr));
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
(after != before).then_some(after)
});
if let Some(code) = changed {
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
new_code.push((c, code, lost));
}
}
if new_code.is_empty() {
continue;
}
let Some(sh) = wb.sheet_mut(si) else { continue };
for (c, code, lost) in new_code {
if let Some(cell) = sh.cells.get_mut(c)
&& let Some(py) = cell.py.as_mut()
{
let py = std::sync::Arc::make_mut(py);
py.code = code;
if lost {
py.output = None;
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
}
}
}
}
Ok(())
}
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
/// for any other form (a name, an error).
fn area_shape(reference: &str) -> Option<(u32, u32)> {
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
Some((r.height(), r.width()))
}
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> {
if py.fits() {
return Ok(());
}
Err(EngineError::Other(format!(
"The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.",
gridcraft_formula::quote_sheet(sheet),
at.a1(),
gridcraft_model::python::MAX_FORMULA_CHARS
)))
}
+68
View File
@@ -192,6 +192,74 @@ fn set_values_can_store_strings_as_the_text_they_are() {
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
/// The result names the strings typed input read as something else, so a script notices without
/// reading the file back (#2255). A formula and a leading apostrophe are not conversions.
#[test]
fn set_values_reports_the_strings_typed_input_read() {
let mut s = Session::new();
s.new_workbook();
let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]);
let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(r["cells"], 6, "{r}");
assert_eq!(r["convertedCount"], 4, "{r}");
assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}");
assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}");
assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}");
assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}");
// The report is a diagnostic, not a second copy of the data: a long input is cut.
let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap();
assert_eq!(long["convertedCount"], 1, "{long}");
let input = long["converted"][0]["input"].as_str().unwrap_or_default();
assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}");
assert!(input.ends_with('…'), "{input}");
// Nothing is read with `parse: false`, so nothing is reported.
let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap();
assert_eq!(verbatim["convertedCount"], 0, "{verbatim}");
assert_eq!(verbatim["converted"], json!([]), "{verbatim}");
}
/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a
/// script clearing cells with "" must not leave cells `COUNTA` counts.
#[test]
fn set_values_parse_false_clears_an_empty_string() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap();
s.execute("home.bold", json!({"range": "A1"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["type"], "empty");
assert_eq!(get(&mut s, "B1")["type"], "empty");
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting");
assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0));
}
/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the
/// default's reading of a date survives as a date (#2255).
#[test]
fn set_values_text_survives_an_xlsx_roundtrip() {
let mut s = Session::new();
s.new_workbook();
let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]);
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap();
s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap();
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
let sh = wb.sheet(0).unwrap();
for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] {
assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given");
}
assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula");
assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0));
assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true));
// The default read the date, and the file keeps it as one.
let at = CellRef::new(2, 0);
assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026");
assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy");
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+305
View File
@@ -0,0 +1,305 @@
//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by
//! `gridcraft-math` and drawn from the cached layout.
//!
//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the
//! frame for the requested font size; editing keeps the current scale and resizes the frame to
//! the new content.
use gridcraft_math::{MathError, MathList};
use gridcraft_model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind};
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.equation",
"Equation",
["Insert", "Symbols"],
None,
"{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}",
has_doc,
insert_equation
),
cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation),
]
}
/// Default equation font size, in points.
pub const DEFAULT_SIZE_PT: f64 = 11.0;
/// Space between an equation's frame and its content, in sheet pixels.
pub const PADDING: f32 = 4.0;
/// Sheet pixels per point.
const PX_PER_PT: f64 = 96.0 / 72.0;
/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels.
pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 {
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch);
if s.is_finite() { s.max(0.0) } else { 0.0 }
}
/// The frame size for `list` at `scale` pixels per em.
fn frame_for(list: &MathList, scale: f32) -> (f32, f32) {
let w = list.width.max(0.5) * scale + 2.0 * PADDING;
let h = list.total_height().max(1.0) * scale + 2.0 * PADDING;
(w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0))
}
/// `latex` laid out, or a readable error with the position of the problem.
fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
gridcraft_math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
}
/// "Error at character 5: Undefined control sequence: \foo".
pub fn describe(latex: &str, e: &MathError) -> String {
match e.char_pos(latex) {
Some(p) => format!("equation error at character {p}: {}", e.message),
None => format!("equation error: {}", e.message),
}
}
fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "insert.equation";
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
if latex.trim().is_empty() {
return Err(bad(CMD, "`latex` is empty"));
}
let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT);
if !(1.0..=409.0).contains(&size) {
return Err(bad(CMD, "`size` must be between 1 and 409 points"));
}
let color = match p.get("color") {
None | Some(Json::Null) => Color::Auto,
Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?,
};
let list = lay_out(CMD, latex)?;
let at = match cell_param(s, p, "cell")? {
Some(c) => Some(c),
None => cell_param(s, p, "at")?,
};
let d = s.doc()?;
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
return Err(EngineError::Other(PROTECTED.into()));
}
let at = at.unwrap_or(d.selection.active);
let id = d.wb.next_object_id();
let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
let mut shape = Shape {
id,
kind: ShapeKind::Equation,
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
fill: Color::Auto,
line: color,
text: latex.to_string(),
format: Default::default(),
math: Default::default(),
};
shape.math.refresh(latex);
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"equation": id}))
})
}
fn edit_equation(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "equation.edit";
let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?;
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?;
if latex.trim().is_empty() {
return Err(bad(CMD, "`latex` is empty; delete the equation instead"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?;
let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?;
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
}
// The same source is a no-op, unless it has no layout yet (a file with more equations than
// are laid out at once): then OK lays it out.
if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
return ok();
}
let list = lay_out(CMD, latex)?;
// Keep the size the equation is drawn at now; without a layout, use the default size.
let scale = match shape.math.get(&shape.text) {
Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height),
_ => 0.0,
};
let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 };
let (width, height) = frame_for(&list, scale);
edit(s, |cx| {
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?;
shape.text = latex.to_string();
shape.math = EquationCache::default();
shape.math.refresh(latex);
shape.anchor.width = width;
shape.anchor.height = height;
Ok(json!({"equation": id}))
})
}
// ---------------------------------------------------------------- PDF
/// The first [`gridcraft_math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char
/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of
/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much
/// and does no work on the rest.
pub fn source_excerpt(text: &str) -> &str {
let cap = gridcraft_math::MAX_SOURCE_BYTES;
if text.len() <= cap {
return text;
}
let mut end = cap;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text.get(..end).unwrap_or("")
}
/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or
/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`.
fn pdf_font(c: char, face: gridcraft_math::Face) -> Option<gridcraft_pdf::Font> {
use gridcraft_math::Face;
use gridcraft_pdf::Font;
let text = match face {
Face::Italic => Font::TimesItalic,
Face::Bold => Font::TimesBold,
Face::BoldItalic => Font::TimesBoldItalic,
Face::Sans => Font::Helvetica,
Face::SansBold => Font::HelveticaBold,
Face::SansItalic => Font::HelveticaOblique,
Face::Mono => Font::Courier,
Face::Roman | Face::Large(_) => Font::TimesRoman,
};
// Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose
// operators (minus, equals, plus) match the maths better than the text faces'.
if c.is_ascii_alphanumeric() {
return Some(text);
}
if Font::Symbol.can_encode(c) {
return Some(Font::Symbol);
}
text.can_encode(c).then_some(text)
}
/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with
/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes.
pub(crate) fn draw_pdf(pg: &mut gridcraft_pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: gridcraft_pdf::Rgb) {
use gridcraft_math::{Face, Item, PathCmd};
let (x, y, w, h) = rect;
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch);
if !(s.is_finite() && s > 0.01) {
return;
}
let s = s.min(4000.0);
let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0);
let col = |c: Option<[u8; 3]>| c.unwrap_or(ink);
for item in list.items.iter().take(gridcraft_math::MAX_ITEMS) {
match item {
Item::Glyph(g) => {
let color = col(g.color);
let (gx, base) = (ox + g.x * s, oy + g.y * s);
// The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge
// at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum.
if g.ch == '\u{221A}' {
let size = (g.height + g.depth) * s / 1.064;
if size.is_finite() && size >= 0.5 {
let top = base - g.height * s;
pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, gridcraft_pdf::Font::Symbol, color, "\u{221A}");
}
continue;
}
let large = matches!(g.face, Face::Large(_));
let size = if large { (g.height + g.depth) * s } else { g.size * s };
if !(size.is_finite() && size >= 0.5) {
continue;
}
let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f))));
match pick {
Some((c, font)) => {
let text = c.to_string();
if large {
// Centred on the glyph's box, like the screen.
let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s;
let tw = gridcraft_pdf::text_width(font, size, &text);
pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text);
} else {
pg.text(gx, base, size, font, color, &text);
}
}
None => {
// [?]: the glyph's box with a question mark.
let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s);
pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color);
let qs = (bh * 0.6).min(bw * 1.2).max(1.0);
let qw = gridcraft_pdf::text_width(gridcraft_pdf::Font::Helvetica, qs, "?");
pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, gridcraft_pdf::Font::Helvetica, color, "?");
}
}
}
Item::Rule(r) => {
if r.width > 0.0 && r.height > 0.0 {
pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color));
}
}
Item::Path(p) => {
let (px, py) = (ox + p.x * s, oy + p.y * s);
let mut subs: Vec<Vec<[f32; 2]>> = vec![];
let mut cur: Vec<[f32; 2]> = vec![];
let at = |a: f32, b: f32| [px + a * s, py + b * s];
for c in &p.commands {
match *c {
PathCmd::MoveTo(a, b) => {
if cur.len() > 1 {
subs.push(std::mem::take(&mut cur));
}
cur = vec![at(a, b)];
}
PathCmd::LineTo(a, b) => cur.push(at(a, b)),
PathCmd::QuadTo(x1, y1, a, b) => {
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
let (c1, p1) = (at(x1, y1), at(a, b));
for k in 1..=8 {
let t = k as f32 / 8.0;
let u = 1.0 - t;
cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]);
}
}
PathCmd::CubicTo(x1, y1, x2, y2, a, b) => {
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b));
for k in 1..=10 {
let t = k as f32 / 10.0;
let u = 1.0 - t;
let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]);
}
}
PathCmd::Close => {
if cur.len() > 1 {
subs.push(std::mem::take(&mut cur));
}
}
}
}
if cur.len() > 1 {
subs.push(cur);
}
let color = col(p.color);
for sub in subs.iter().filter(|v| v.len() <= 4000) {
if p.fill && sub.len() >= 3 {
// The PDF fill rule handles concave outlines itself.
pg.polygon(sub, Some(color), None);
} else {
pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]);
}
}
}
}
}
}
+8 -8
View File
@@ -3,8 +3,8 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
Workbook,
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, ShapeKind, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline,
TrendlineKind, Workbook,
};
use serde_json::{Value as Json, json};
@@ -28,7 +28,7 @@ use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}
pub(crate) use scripts::has_scripts;
use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts};
use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window};
pub(crate) use window::{keep_watch_list, window_param};
pub(crate) use window::{keep_view_modes, keep_watch_list, window_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Chart Element",
["Chart Design", "Chart Layouts"],
None,
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
"{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
has_doc,
add_element
),
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, id, toFront?: bool}",
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, id, toBack?: bool}",
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, ids: [..], align: left|center|right|top|middle|bottom}",
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
@@ -310,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
new_cell_style
),
cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))),
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used),
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used),
cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))),
cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))),
cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))),
+4 -18
View File
@@ -51,6 +51,7 @@ pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& cell.formula.is_none()
&& cell.py.is_none()
{
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
@@ -122,25 +123,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
/// ([`crate::Prefs::recent_functions`]).
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
let mut seen: Vec<String> = Vec::new();
for (id, p) in s.journal.iter().rev() {
if id != "cell.set" {
continue;
}
let input = p.get("input").and_then(Json::as_str).unwrap_or("");
if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
for f in e.functions() {
if !seen.iter().any(|x| x == f) {
seen.push(f.to_string());
}
}
}
if seen.len() >= 10 {
break;
}
}
Ok(json!(seen))
Ok(json!(s.prefs.recent_functions))
}
pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
+1
View File
@@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
}
}
"dataLabels" => c.data_labels = on,
"dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?,
"gridlines" => c.gridlines = on,
"axisTitles" => {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
+62 -28
View File
@@ -1,8 +1,29 @@
use gridcraft_model::ObjKind;
use super::chart::format_series;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, ShapeKind, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param,
u32_param,
};
/// The kind of object a `kind` parameter names: `shape` when the parameter is absent; one that
/// is present but not one of the names (`null`, a number, an unknown word) is an error, as for
/// the `object.*` commands, where the kind is required.
fn kind_param(p: &Json, cmd: &str) -> Result<ObjKind> {
let err = || bad(cmd, "kind must be chart, image, shape, slicer or equation");
match p.get("kind") {
None => Ok(ObjKind::Shape),
Some(Json::String(k)) => ObjKind::parse(k).ok_or_else(err),
Some(_) => Err(err()),
}
}
/// Whether `kind` names the shape `sh`: an equation is a shape too, and a plain shape is not an
/// equation, as `Sheet::object_anchor` has it for the `object.*` commands.
fn shape_is(kind: ObjKind, sh: &Shape) -> bool {
kind != ObjKind::Equation || sh.kind == ShapeKind::Equation
}
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
@@ -33,7 +54,7 @@ fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<J
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let kind = kind_param(p, "arrange")?;
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
@@ -51,23 +72,17 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
}
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
match kind.as_str() {
"chart" => {
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
reorder(&mut sh.charts, i, forward, extreme);
}
// An id that is not an object of this kind is an error, so a stale id, or a shape's id
// given as an equation, never reorders something else or succeeds silently.
let found = match kind {
ObjKind::Chart => sh.charts.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.charts, i, forward, extreme)),
ObjKind::Image => sh.images.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.images, i, forward, extreme)),
ObjKind::Slicer => sh.slicers.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.slicers, i, forward, extreme)),
ObjKind::Shape | ObjKind::Equation => {
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
}
"image" => {
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
reorder(&mut sh.images, i, forward, extreme);
}
}
_ => {
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
reorder(&mut sh.shapes, i, forward, extreme);
}
}
}
};
found.ok_or_else(|| bad("arrange", "no such object"))?;
Ok(Json::Null)
})
}
@@ -77,7 +92,8 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
let kind = if c.kind == ShapeKind::Equation { "equation" } else { "shape" };
v.push(json!({"kind": kind, "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
@@ -85,33 +101,51 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
for c in &sh.charts {
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
// Slicers paint above the other objects.
for c in &sh.slicers {
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
/// The anchors of every object of `kind` on the sheet; under `equation`, only the equations.
fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
match kind {
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
}
}
pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let kind = kind_param(p, "arrange.align")?;
// `ids` may be absent (nothing to align) but, present, must be an array whose every entry is
// an object id naming an object of the kind: a stale or mismatched id, or a value of another
// shape, is an error, as for `arrange` and the `object.*` commands, not something silently
// left out.
let bad_ids = || bad("arrange.align", "ids must be an array of object ids");
let ids: Vec<u32> = match p.get("ids") {
None => Vec::new(),
Some(Json::Array(a)) => a.iter().map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).ok_or_else(bad_ids)).collect::<Result<_>>()?,
Some(_) => return Err(bad_ids()),
};
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
let shc = sheet.clone();
anchors_of(sheet, &kind)
anchors_of(sheet, kind)
.into_iter()
.filter(|(id, _)| ids.contains(id))
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
.collect()
};
if let Some(missing) = ids.iter().find(|id| !geo.iter().any(|g| g.0 == **id)) {
return Err(bad("arrange.align", format!("no such object: {missing}")));
}
if geo.len() < 2 {
return Ok(Json::Null);
}
@@ -120,7 +154,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
let shc = sheet.clone();
for (id, a) in anchors_of(sheet, &kind) {
for (id, a) in anchors_of(sheet, kind) {
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
let (mut x, mut y) = (g.1, g.2);
match how.as_str() {
+42 -7
View File
@@ -1,10 +1,15 @@
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
let cmds: Vec<Json> =
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect();
let cmds: Vec<Json> = s
.journal
.iter()
.skip(start)
.filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str()))
.map(|(id, p)| json!({"command": id, "params": p}))
.collect();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
@@ -104,12 +109,16 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
// What ran, for a recorder in progress: the recording keeps these steps with the run, so
// editing or deleting the script before the recording stops does not change what it saved.
let mut ran_steps: Vec<(String, Json)> = Vec::new();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
let before = s.ui_requests.len();
let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone()));
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
@@ -120,17 +129,43 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
let result = ran?;
n += 1;
if s.recorder.is_some() {
// The recorder's rule for each step, as for a command run on its own: a menu-style
// step that only opened a dialog is left out (playing it back would stop at the
// dialog), and a toggle keeps the state it set.
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
if !opened_dialog || changed {
ran_steps.push((id.to_string(), crate::record::with_toggle_state(id, st["params"].clone(), &result)));
}
}
}
if s.recorder.is_some() {
s.script_ran = Some(ran_steps);
}
Ok(json!({"ran": n}))
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
/// Redo work there again. A macro being debugged on that workbook is stopped with them.
pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
// Inside another command the open groups are that command's own (an action in progress):
// a step of it that stopped them would pull the workbook from under the rest of its run.
if s.depth() > 1 {
return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into()));
}
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
let mut out = json!({"stopped": s.abort_groups(uid)});
// A macro being debugged here had its group among them: its run is over.
if let Some((name, wrote_files)) = crate::macros::debug::end_stopped(s, uid) {
out["macro"] = json!(name);
out["message"] = json!(format!(
"Macro '{name}', which was being debugged, was stopped and rolled back. {}",
crate::macros::run::rolled_back(&wrote_files)
));
}
Ok(out)
}
pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
+29
View File
@@ -38,6 +38,35 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
wb.names.extend(keep);
}
/// Normal, Page Layout and Page Break Preview belong to the window, not to the edits: Excel doesn't
/// undo a switch between them, and `set_view` is no undo step. So undo and redo keep each sheet's
/// current view when they swap in another snapshot of the workbook, and a view kept this way
/// doesn't make a saved workbook dirty.
pub(crate) fn keep_view_modes(d: &mut crate::DocState, now: &Workbook) {
// Sheets have no stable id. A name missing from `now` is a rename being undone or redone, which
// keeps the sheet count and position: the sheet is the one at the same index, under a name the
// restored snapshot doesn't have.
let renamed = |i: usize| {
(now.sheets.len() == d.wb.sheets.len()).then(|| now.sheets.get(i)).flatten().filter(|sh| !d.wb.sheets.iter().any(|s| s.name == sh.name))
};
let current = |i: usize, name: &str| now.sheets.iter().find(|sh| sh.name == name).or_else(|| renamed(i)).map(|sh| sh.view);
let changed: Vec<(usize, SheetViewMode)> =
d.wb.sheets.iter().enumerate().filter_map(|(i, sh)| current(i, &sh.name).filter(|v| *v != sh.view).map(|v| (i, v))).collect();
if changed.is_empty() {
return;
}
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
for (i, v) in changed {
if let Some(sh) = wb.sheet_mut(i) {
sh.view = v;
}
}
if !dirty {
d.saved = d.wb.clone();
}
}
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_map(&d.wb, VIEWS_NAME);
+6 -2
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
crate::io::csv_values_bytes(&wb)?
} else {
crate::io::save_bytes(&wb, &target)?
};
crate::io::write_file(&target, &bytes)?;
Ok(json!({"path": target, "bytes": bytes.len()}))
}
+2 -1
View File
@@ -315,7 +315,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
let mut sheet = Sheet::new(sheet_name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
};
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
let mut cell = Cell::formula(Formula::new(&text));
@@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
series,
legend: LegendPos::Bottom,
data_labels: false,
data_table: None,
gridlines: true,
style: 1,
x_title: None,
+14 -7
View File
@@ -90,8 +90,14 @@ pub fn specs() -> Vec<CommandSpec> {
format_cells
),
// Alignment
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
CommandSpec {
shortcut_mac: Some("Cmd+L"),
..cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left))
},
CommandSpec {
shortcut_mac: Some("Cmd+E"),
..cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center))
},
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
@@ -650,8 +656,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
cell.clear_content();
sh.set_cell(c, cell);
}
touched.push((sheet, c));
@@ -668,8 +673,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
cell.clear_content();
sh.set_cell(c, cell);
}
touched.push((sheet, c));
@@ -686,7 +690,10 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let blocked = sh
.cells
.iter_range(reach)
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
// A Python cell's result is blocked and unblocked like a formula's.
.filter(|(_, x)| {
(x.formula.is_some() || x.py.is_some()) && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill)
});
touched.extend(blocked.map(|(c, _)| (sheet, c)));
}
cx.changed.extend(touched);
+83 -1
View File
@@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(
query "formulas.traceError",
"Trace Error",
["Formulas", "Formula Auditing"],
None,
"{cell?} → {sheet, cell, error, source: {sheet, cell}, arrows: [{from: {sheet, range}, to: {sheet, cell}, kind: \"error\"|\"precedent\"}], truncated}: from the cell, follows the first reference that shows the same error (in a range, its first cell showing it) back to the cell the error starts in, `source`. `error` arrows link the cells on the way; `precedent` arrows lead from the source's own references into it. A cell without an error gives `error` and `source` null and no arrows. A chain longer than 10,000 links stops there with `truncated` true and `source` null.",
has_doc,
trace_error
),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
@@ -626,7 +635,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(c);
// The second column holds the refers-to text as a text value, with no formula.
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style, py: None });
cx.touch(sheet, c);
}
}
@@ -653,6 +662,79 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
Ok(Json::Array(v))
}
/// Trace Error goes back at most this many links; a longer chain stops there.
const TRACE_ERROR_LINKS: usize = 10_000;
/// Trace Error: from the cell, the first reference in its formula that shows the same error
/// (in a range, the range's first cell showing it, row by row), then that cell's, and so on to the
/// cell the error starts in: one none of whose references shows it, or one without references.
/// Arrows link the cells on the way, and lead from the source's own references into it.
fn trace_error(s: &mut Session, p: &Json) -> Result<Json> {
use std::collections::HashSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet();
let name = |si: usize| d.wb.sheet(si).map(|sh| sh.name.clone());
let cell = |(si, c): (usize, CellRef)| json!({"sheet": name(si), "cell": c.a1()});
let Some(error) = d.wb.sheet(sheet).and_then(|sh| sh.value(at).as_error()) else {
return Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": null, "source": null, "arrows": [], "truncated": false}));
};
// A value spilled by a dynamic array comes from its anchor's formula.
let precedents = |(si, c): (usize, CellRef)| {
let anchor = d.wb.sheet(si).filter(|sh| sh.cell(c).is_none_or(|x| x.formula.is_none())).and_then(|sh| sh.spill_anchor(c));
d.calc.graph.precedents_of((si, anchor.unwrap_or(c)))
};
// The walk keeps to cells it hasn't been to, so a circular reference ends it.
let mut chain = vec![(sheet, at)];
let mut seen: HashSet<(usize, CellRef)> = HashSet::from([(sheet, at)]);
let mut truncated = false;
let mut last = (sheet, at);
while let Some(next) = precedents(last).iter().find_map(|a| first_showing(&d.wb, a, error)) {
if !seen.insert(next) {
break;
}
if chain.len() > TRACE_ERROR_LINKS {
truncated = true;
break;
}
chain.push(next);
last = next;
}
let mut arrows = Vec::new();
// A chain cut short hasn't reached its source.
if !truncated {
let mut drawn = HashSet::new();
for a in precedents(last) {
if drawn.insert(a) {
arrows.push(json!({"from": {"sheet": name(a.sheet), "range": a.range.a1()}, "to": cell(last), "kind": "precedent"}));
}
}
}
// From the source outwards, as the error travels.
for pair in chain.windows(2).rev() {
if let [to, from] = pair {
arrows.push(json!({"from": {"sheet": name(from.0), "range": from.1.a1()}, "to": cell(*to), "kind": "error"}));
}
}
let source = if truncated { Json::Null } else { cell(last) };
Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": error.as_str(), "source": source, "arrows": arrows, "truncated": truncated}))
}
/// The first cell of an area, row by row, that shows `error`: a stored cell or a value spilled
/// into the area by a dynamic array.
fn first_showing(wb: &Workbook, a: &gridcraft_calc::Area, error: CellError) -> Option<(usize, CellRef)> {
let sh = wb.sheet(a.sheet)?;
let holds = |c: &Cell| !c.value.is_empty() || c.formula.is_some();
let stored = sh.cells.iter_range(a.range).find(|(_, c)| holds(c) && c.value.as_error() == Some(error)).map(|(at, _)| at);
let spilled = sh
.spill
.range(a.range.start..)
.take_while(|(at, _)| **at <= a.range.end)
.find(|(at, v)| a.range.contains(**at) && v.as_error() == Some(error) && !sh.cell(**at).is_some_and(holds))
.map(|(at, _)| *at);
stored.into_iter().chain(spilled).min().map(|c| (a.sheet, c))
}
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -260,6 +260,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
@@ -333,6 +333,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
line: Color::Auto,
text: name,
format: Default::default(),
math: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
+1
View File
@@ -153,6 +153,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
line,
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
math: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
+1 -1
View File
@@ -60,7 +60,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e)?;
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
+9 -2
View File
@@ -9,7 +9,7 @@ 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 "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 "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link, python {code, output, typeName, preview, stale}", 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 "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"freeze": sh.freeze,
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"images": sh.images.len(),
"shapes": sh.shapes.len(),
"conditionalFormats": sh.cond_formats.len(),
@@ -137,6 +137,13 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
"merge": sh.merge_at(c).map(|m| m.a1()),
"comment": sh.comments.get(&c),
"hyperlink": sh.hyperlinks.get(&c),
"python": cell.and_then(|x| x.py.as_deref()).map(|py| json!({
"code": py.code,
"output": if py.object { "object" } else { "value" },
"typeName": py.output.as_ref().map(|o| o.type_name.as_str()),
"preview": py.output.as_ref().map(|o| o.preview.as_str()),
"stale": py.stale,
})),
}))
}
+372
View File
@@ -0,0 +1,372 @@
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate,
//! older feature with their own storage; these commands neither read nor change it. A recording
//! keeps the steps a script run ran while it recorded, as the run reported them.
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param};
use crate::macros::recorder::RecordingStart;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list),
cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get),
cmd!(
"macro.save",
"Save Macro",
[],
None,
"{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default false: saving over a macro with the name is refused)} → {name, steps}",
has_doc,
save
),
cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete),
cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename),
cmd!(
"macro.run",
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
has_doc,
run
),
cmd!(
query "macro.check",
"Check Macro",
[],
None,
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
has_doc,
check
),
cmd!(
noundo "macro.record",
"Record Macro",
["View", "Macros"],
None,
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
has_doc,
record
),
cmd!(
"macro.debug",
"Step Into",
[],
None,
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
has_doc,
|s, p| crate::macros::debug::debug(s, p)
),
]
}
fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
}
/// The index of macro `name` in the active workbook, or an error that says there is none.
fn index_of(s: &Session, name: &str) -> Result<usize> {
crate::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
Ok(Json::Array(
wb.macros
.iter()
.map(|m| {
let mut o = json!({
"name": m.name,
"description": m.description,
"steps": m.step_count(),
"inputs": m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>(),
"onConflict": m.output.on_conflict.name(),
"permissions": m.permissions,
});
if let Some(k) = &m.shortcut {
o["shortcut"] = json!(k);
}
o
})
.collect(),
))
}
fn get(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.get", p)?)?;
let wb = &s.doc()?.wb;
Ok(wb.macros.get(i).map(crate::macros::macro_json).unwrap_or(Json::Null))
}
fn save(s: &mut Session, p: &Json) -> Result<Json> {
let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p);
let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?;
// Replacing is asked for, never assumed: a save with a name in use is refused.
let replace = match p.get("replace") {
None | Some(Json::Null) => false,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("macro.save", "`replace` must be true or false")),
};
let (name, steps) = (m.name.clone(), m.step_count());
edit(s, |cx| {
if let Some(kept) = crate::macros::kept_name(&cx.wb, &m.name) {
return Err(EngineError::Other(taken_by_kept(&kept)));
}
match crate::macros::find(&cx.wb, &m.name) {
Some(i) if !replace => {
let existing = cx.wb.macros.get(i).map_or(m.name.as_str(), |o| o.name.as_str());
return Err(EngineError::Other(format!("A macro called '{existing}' already exists. Pass `replace: true` to replace it.")));
}
Some(i) => {
if let Some(old) = cx.wb.macros.get_mut(i) {
*old = m;
}
}
None => {
crate::macros::check_room(&cx.wb).map_err(EngineError::Other)?;
cx.wb.macros.push(m);
}
}
Ok(())
})?;
Ok(json!({"name": name, "steps": steps}))
}
/// The refusal of a name that a macro kept in a newer format has.
fn taken_by_kept(name: &str) -> String {
format!(
"A macro called '{name}' made by a newer version of GridCraft is kept in this workbook; this version can't change it. Choose another name."
)
}
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.delete", p)?)?;
edit(s, |cx| {
if i < cx.wb.macros.len() {
cx.wb.macros.remove(i);
}
Ok(Json::Null)
})
}
fn rename(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.rename", p)?)?;
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string();
if to.chars().any(char::is_control) {
return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters"));
}
let wb = &s.doc()?.wb;
if crate::macros::find(wb, &to).is_some_and(|j| j != i) {
return Err(EngineError::Other(format!("A macro called '{to}' already exists.")));
}
if let Some(kept) = crate::macros::kept_name(wb, &to) {
return Err(EngineError::Other(taken_by_kept(&kept)));
}
edit(s, |cx| {
let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) };
let mut renamed = m.clone();
renamed.name = to.clone();
// The whole check, size included: a longer name can take a macro past what a file holds.
crate::macros::check(&renamed).map_err(EngineError::Other)?;
*m = renamed;
Ok(())
})?;
Ok(json!({"name": to}))
}
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.run", p)?;
// While recording: the steps a recording keeps for this run, taken from the macro as it runs,
// so saving or deleting the macro before the recording stops doesn't change what it saved.
let inline = if s.is_recording() {
let wb = &s.doc()?.wb;
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::macros::recorder::inline_steps)
} else {
None
};
let out = crate::macros::run::run(s, name, p)?;
if s.is_recording() {
// `None` when the macro's steps don't stand on their own: the recording leaves the run out.
s.script_ran = inline;
}
Ok(out)
}
fn check(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.check", p)?;
crate::macros::run::check(s, name, p)
}
fn record(s: &mut Session, p: &Json) -> Result<Json> {
check_record_params(s.is_recording(), p)?;
// `cancel` ends a recording without saving it: the way out of a recording whose macro
// cannot be saved (a name the check refuses, a full workbook).
if bool_param(p, "cancel").unwrap_or(false) {
let was = s.stop_recording().is_some();
s.macro_recording = None;
return Ok(json!({"recording": false, "cancelled": was}));
}
if bool_param(p, "stop").unwrap_or(false) || s.is_recording() {
return stop_recording(s, p);
}
start_recording(s, p, bool_param(p, "relative").unwrap_or(true))
}
/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and
/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while
/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong
/// kind. `name` and `description` count when starting and when stopping (stopping wins).
fn check_record_params(recording: bool, p: &Json) -> Result<()> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
for k in ["stop", "cancel", "relative"] {
if given(k).is_some_and(|v| !v.is_boolean()) {
return Err(bad("macro.record", format!("`{k}` must be true or false")));
}
}
for k in ["name", "description", "shortcut"] {
if given(k).is_some_and(|v| !v.is_string()) {
return Err(bad("macro.record", format!("`{k}` must be text")));
}
}
if given("cancel").and_then(Json::as_bool) == Some(true) {
if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) {
return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored")));
}
return Ok(());
}
let stop = given("stop").and_then(Json::as_bool);
if recording && stop == Some(false) {
return Err(bad(
"macro.record",
"a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved",
));
}
if (recording || stop == Some(true))
&& let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some())
{
return Err(bad("macro.record", format!("`{k}` is chosen when recording starts")));
}
Ok(())
}
/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are
/// checked now, so a bad name or a taken shortcut is refused before anything is recorded.
fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?),
None => None,
};
let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) {
Some(k) => {
let letter = crate::macros::parse_shortcut(k)
.ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?;
if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) {
return Err(bad("macro.record", why));
}
Some(crate::macros::shortcut_text(letter))
}
None => None,
};
let description = match str_param(p, "description") {
Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => {
return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT)));
}
Some(t) => Some(t.to_string()),
None => None,
};
let mut start = RecordingStart::new(d, relative);
(start.name, start.shortcut, start.description) = (name.clone(), shortcut, description);
let input = start.selection.current().a1();
s.start_recording();
s.macro_recording = Some(start);
let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) };
if let Some(n) = name {
out["name"] = json!(n);
}
Ok(out)
}
/// Stops recording and saves what was recorded as a macro, in the workbook where recording
/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the
/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed,
/// the recording ends unsaved.
fn stop_recording(s: &mut Session, p: &Json) -> Result<Json> {
let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) };
let start = s.macro_recording.take();
let at = match &start {
Some(st) => s
.docs
.iter()
.position(|d| d.uid == st.doc)
.ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?,
None => s.active_index(),
};
let saved = save_recording(s, p, &rec, start.clone(), at);
if saved.is_err() {
s.recorder = Some(rec);
s.macro_recording = start;
}
saved
}
/// Saves the recording into the workbook at index `at`.
fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option<RecordingStart>, at: usize) -> Result<Json> {
let d = s.docs.get(at).ok_or(EngineError::NoDocument)?;
// A macro being debugged there could still roll the saved macro back with its run.
if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) {
return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into()));
}
// A recording started without `macro.record` (`Session::start_recording`) takes its example
// input from the workbook as it is now.
let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
let (doc, wb) = (d.uid, d.wb.clone());
// A name given when stopping wins over the one given when starting; either one, if another
// macro has it, gets a suffix, so the save never replaces a macro.
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) {
Some(n) => crate::macros::unique_name(&wb, n),
None => crate::macros::next_name(&wb),
};
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran);
// Steps past the macro limit or its size, and ones recorded in another workbook, are left
// out and counted with the ones the recorder dropped.
let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS);
if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) {
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
}
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
// The shortcut, unless another macro took it while recording; then it is left off and the
// result says so.
let mut shortcut_dropped = None;
if let Some(k) = &start.shortcut {
if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) {
converted.mac.shortcut = Some(k.clone());
} else {
shortcut_dropped = Some(k.clone());
}
}
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
// Saved into that workbook even when another one is active now.
let active = s.active_index();
s.set_active(at);
let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false}));
s.set_active(active);
saved?;
// The person recorded it, so it runs without the approval a macro that came with a file
// needs, until it changes.
if let Some(d) = s.docs.iter().find(|d| d.uid == doc)
&& let Some(m) = crate::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i))
{
let key = (doc, crate::macros::fingerprint(m));
s.macro_approvals.insert(key);
}
let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
if let Some(k) = shortcut_dropped {
out["shortcutDropped"] = json!(k);
}
Ok(out)
}
+1
View File
@@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
+5 -2
View File
@@ -255,7 +255,10 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
}
}
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() });
sh.set_cell(
CellRef::new(0, c as u32),
Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default(), py: None },
);
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
@@ -264,7 +267,7 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
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 });
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style, py: None });
}
}
let name = sh.name.clone();
+26 -12
View File
@@ -1,20 +1,20 @@
//! Printing: Page Setup, pagination and PDF output (File › Print, File › Export › PDF).
//!
//! Sheets are paginated like Excel's default "down, then over": the print area (or the used
//! range plus drawing objects) is split into bands of rows and columns that fit the printable
//! area at the page scale, honouring manual breaks, repeated title rows/columns, headings, Fit
//! To and centring. Each page is drawn into a PDF with `gridcraft-pdf`: fills, gridlines,
//! borders, formatted text (fonts mapped to the standard 14 PDF fonts), merged cells, wrapping,
//! overflow into empty neighbours, conditional formats, table styles, charts, images, shapes and
//! sparklines. Headers and footers support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z`
//! codes.
//! Sheets are paginated like Excel: the print area (or the used range plus drawing objects) is
//! split into bands of rows and columns that fit the printable area at the page scale, honouring
//! manual breaks, repeated title rows/columns, headings, Fit To and centring, and the pages follow
//! the sheet's page order ("down, then over" by default, or "over, then down"). Each page is
//! drawn into a PDF with `gridcraft-pdf`: fills, gridlines, borders, formatted text (fonts mapped
//! to the standard 14 PDF fonts), merged cells, wrapping, overflow into empty neighbours,
//! conditional formats, table styles, charts, images, shapes and sparklines. Headers and footers
//! support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` codes.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
#[cfg(test)]
use gridcraft_model::{Alignment, Image, SheetViewMode};
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
Underline, VAlign, Visibility, Workbook,
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId,
StyleTable, Underline, VAlign, Visibility, Workbook,
};
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
use serde_json::{Value as Json, json};
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Preview",
["File"],
None,
"{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, width, height}]}",
"{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, pageOrder, width, height}]} in print order",
has_doc,
print_preview
),
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Page Setup…",
["Page Layout", "Page Setup"],
None,
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"], pageOrder?: downThenOver|overThenDown} → the sheet's print settings",
has_doc,
page_setup
),
@@ -222,6 +222,7 @@ fn print_preview(s: &mut Session, p: &Json) -> Result<Json> {
"scale": (lay.scale * 1000.0).round() / 10.0,
"paper": lay.settings.paper,
"orientation": if lay.settings.orientation == Orientation::Landscape { "landscape" } else { "portrait" },
"pageOrder": lay.settings.page_order.id(),
"width": lay.page_w,
"height": lay.page_h,
}));
@@ -244,6 +245,16 @@ fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
Some("landscape") => Some(Orientation::Landscape),
Some(o) => return Err(bad(CMD, format!("orientation must be portrait or landscape, not `{o}`"))),
};
let page_order = match p.get("pageOrder") {
None => None,
Some(v) => match v.as_str().and_then(PageOrder::parse) {
Some(o) => Some(o),
None => {
let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string);
return Err(bad(CMD, format!("pageOrder must be downThenOver or overThenDown, not `{shown}`")));
}
},
};
let paper = match str_param(p, "paper") {
None => None,
Some(name) => {
@@ -385,6 +396,9 @@ fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(b) = &col_breaks {
pr.col_breaks = b.clone();
}
if let Some(o) = page_order {
pr.page_order = o;
}
Ok(())
})?;
let pr = s.doc()?.wb.sheet(si).map(|sh| sh.print.clone()).unwrap_or_default();
+36 -2
View File
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
Workbook, split_sheet,
};
// ---------------------------------------------------------------- drawing
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|| merges.iter().any(|m| m.start == *c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
}
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
draw_checkbox(pg, (x, y, w, h), b.k, on);
} else {
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
}
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
pg.restore();
}
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
/// print as text.
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
return None;
}
match v {
Value::Bool(on) => Some(*on),
Value::Empty => Some(false),
_ => None,
}
}
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
if s <= 0.0 {
return;
}
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
if on {
let green = [0x10, 0x7C, 0x41];
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
} else {
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
}
}
pub(super) struct PendingPdfLink {
pub(super) page: usize,
pub(super) sheet: usize,
+22 -11
View File
@@ -1,7 +1,7 @@
use super::header_footer::HF_FIXED_SIDE_MARGIN;
use super::{
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
bool_param, split_sheet, str_param,
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PageOrder, PrintSettings, RangeRef, Result, Sheet, Visibility,
Workbook, bad, bool_param, split_sheet, str_param,
};
/// Cells in a page block above which only stored cells are visited (not every grid position).
@@ -253,15 +253,7 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
let paginate = |lay: &mut SheetLayout, manual: bool| {
lay.manual_breaks = manual;
(lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES);
lay.pages.clear();
'outer: for ci in 0..lay.col_bands.len() {
for ri in 0..lay.row_bands.len() {
if lay.pages.len() >= MAX_PAGES {
break 'outer;
}
lay.pages.push((ri, ci));
}
}
lay.pages = page_sequence(lay.row_bands.len(), lay.col_bands.len(), lay.settings.page_order, MAX_PAGES);
};
match fit {
Some((fw, fh)) => {
@@ -290,6 +282,25 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(lay)
}
/// The pages of a sheet `rows` pages tall and `cols` pages wide as (row band, column band), in
/// print order, at most `cap` of them: down each column of pages, then over to the next (Excel's
/// default), or over each row of pages, then down to the next.
pub(crate) fn page_sequence(rows: usize, cols: usize, order: PageOrder, cap: usize) -> Vec<(usize, usize)> {
let (outer, inner) = match order {
PageOrder::DownThenOver => (cols, rows),
PageOrder::OverThenDown => (rows, cols),
};
(0..outer)
.flat_map(|o| {
(0..inner).map(move |i| match order {
PageOrder::DownThenOver => (i, o),
PageOrder::OverThenDown => (o, i),
})
})
.take(cap)
.collect()
}
/// Inclusive-start/end index spans of the pages along one axis.
pub(crate) type Bands = Vec<(u32, u32)>;
+39
View File
@@ -29,6 +29,26 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
}
/// Sheet pixel rectangle of an anchor.
/// Most source lines printed for an equation that has no layout.
pub(super) const MAX_FALLBACK_LINES: usize = 500;
/// The lines of `text` printed in place of an equation that has no layout: those of its first
/// `MAX_SOURCE_BYTES` (a longer source never lays out, and a file can carry megabytes) that fit a
/// frame `h` points tall at `line_h` points per line (at least the first), and never more than
/// [`MAX_FALLBACK_LINES`]. A single `pg.text` call would print every newline as `?`.
pub(super) fn fallback_lines(text: &str, h: f32, line_h: f32) -> impl Iterator<Item = &str> {
let text = crate::cmd::equation::source_excerpt(text);
let fit = h / line_h;
let n = if fit >= MAX_FALLBACK_LINES as f32 {
MAX_FALLBACK_LINES
} else if fit >= 1.0 {
fit as usize
} else {
1
};
text.lines().take(n)
}
fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) {
(sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64)
}
@@ -79,6 +99,25 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
continue;
}
let (x, y, w, h) = place(r);
if shp.kind == ShapeKind::Equation {
let ink = rgb_of(shp.line, wb).unwrap_or([0, 0, 0]);
match shp.math.get(&shp.text) {
Some(Ok(list)) => crate::cmd::equation::draw_pdf(pg, list, (x, y, w, h), crate::cmd::equation::PADDING * b.k, ink),
_ => {
// No layout (the source does not parse, or the file was past the layout
// budget): the source, one text run per line, as many as the frame holds.
let line_h = 12.0 * b.scale;
// A long line stays inside the frame; this clip nests in the block's.
pg.save();
pg.clip_rect(x, y, w, h);
for (i, line) in fallback_lines(&shp.text, h, line_h).enumerate() {
pg.text(x + 2.0 * b.k, y + line_h * (i + 1) as f32, 10.0 * b.scale, Font::Courier, ink, line);
}
pg.restore();
}
}
continue;
}
draw_shape(pg, wb, shp, b, (x, y, w, h));
}
for ch in &sh.charts {
+294
View File
@@ -16,6 +16,25 @@ fn contains(hay: &[u8], needle: &str) -> bool {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn chart_data_table_prints_formatted_values_and_follows_recalculation() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 1234], ["Q2", 5678]]})).unwrap();
s.execute("home.numberFormat", json!({"range": "B2:B3", "format": "#,##0.00"})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B3", "at": "D2", "type": "column"})).unwrap();
let (off, pages) = pdf_of(&mut s, json!({"fitToPage": true}));
assert_eq!(pages, 1);
let count = |bytes: &[u8], text: &str| String::from_utf8_lossy(bytes).matches(text).count();
s.execute("chart.set", json!({"dataTable": true})).unwrap();
let (on, _) = pdf_of(&mut s, json!({"fitToPage": true}));
assert_eq!(count(&on, "(1,234.00) Tj"), count(&off, "(1,234.00) Tj") + 1);
assert_eq!(count(&on, "(5,678.00) Tj"), count(&off, "(5,678.00) Tj") + 1);
s.execute("cell.set", json!({"cell": "B2", "input": "=1000+111"})).unwrap();
let (updated, _) = pdf_of(&mut s, json!({"fitToPage": true}));
assert!(!contains(&updated, "(1,234.00) Tj"));
assert!(count(&updated, "(1,111.00) Tj") >= 2, "cell and chart data table both print the new value");
}
#[test]
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
let mut s = s();
@@ -148,6 +167,142 @@ fn wide_sheet_goes_down_then_over() {
assert_eq!(pv["pageList"][2]["range"], "K1:O45");
}
/// The ranges of the printed pages, in print order.
fn page_ranges(s: &mut Session) -> Vec<String> {
let pv = s.execute("file.printPreview", json!({})).unwrap();
let list = pv["pageList"].as_array().unwrap();
assert_eq!(pv["pages"], list.len());
for (i, page) in list.iter().enumerate() {
assert_eq!(page["page"], i + 1);
assert_eq!(page["pageOrder"], s.doc().unwrap().wb.active().unwrap().print.page_order.id());
}
list.iter().map(|p| p["range"].as_str().unwrap().to_string()).collect()
}
/// The content streams of an exported PDF's pages, in page order (pages without pictures are the
/// file's only streams).
fn pdf_pages(s: &mut Session) -> Vec<String> {
let (pdf, pages) = pdf_of(s, json!({}));
let text = String::from_utf8_lossy(&pdf);
let streams: Vec<String> = text.split("endstream").filter(|part| part.contains("stream\n")).map(str::to_string).collect();
assert_eq!(streams.len() as u64, pages);
streams
}
/// The page order measured in Excel: print area A1:D4 with a row break before row 3 and a column
/// break before column C, so four pages two tall and two wide, each filled and labelled in its
/// own colour, with `Page &P of &N` in the footer.
fn two_by_two_pages() -> Session {
let mut s = s();
for (range, color, label) in
[("A1:B2", "#FF0000", "red"), ("A3:B4", "#00FF00", "green"), ("C1:D2", "#0000FF", "blue"), ("C3:D4", "#FFFF00", "yellow")]
{
s.execute("home.fillColor", json!({"range": range, "color": color})).unwrap();
s.execute("cell.set", json!({"cell": &range[..2], "input": label})).unwrap();
}
s.execute("pageLayout.pageSetup", json!({"printArea": "A1:D4", "rowBreaks": [3], "colBreaks": ["C"], "footer": "Page &P of &N"})).unwrap();
s
}
/// The colour of each printed page, read from its fill and label; each page's footer must
/// number it in print order.
fn printed_colours(s: &mut Session) -> Vec<&'static str> {
let pages = pdf_pages(s);
let n = pages.len();
pages
.iter()
.enumerate()
.map(|(i, page)| {
assert!(page.contains(&format!("(Page {} of {n}) Tj", i + 1)), "page {}: {page}", i + 1);
let found: Vec<&str> = [("red", "1 0 0 rg"), ("green", "0 1 0 rg"), ("blue", "0 0 1 rg"), ("yellow", "1 1 0 rg")]
.into_iter()
.filter(|(label, fill)| page.contains(&format!("({label}) Tj")) && page.contains(&format!("\n{fill}\n")))
.map(|(label, _)| label)
.collect();
assert_eq!(found.len(), 1, "page {}: {found:?}", i + 1);
found[0]
})
.collect()
}
#[test]
fn page_order_sets_the_sequence_of_the_pages_as_measured_in_excel() {
let mut s = two_by_two_pages();
// Down, then over is the default: down the first column of pages, then the next.
assert_eq!(s.doc().unwrap().wb.active().unwrap().print.page_order, PageOrder::DownThenOver);
assert_eq!(s.execute("pageLayout.pageSetup", json!({})).unwrap()["page_order"], "downThenOver");
let down = ["A1:B2", "A3:B4", "C1:D2", "C3:D4"];
assert_eq!(page_ranges(&mut s), down);
assert_eq!(printed_colours(&mut s), ["red", "green", "blue", "yellow"]);
// Over, then down: across the first row of pages, then the next.
let r = s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
assert_eq!(r["page_order"], "overThenDown");
let over = ["A1:B2", "C1:D2", "A3:B4", "C3:D4"];
assert_eq!(page_ranges(&mut s), over);
assert_eq!(printed_colours(&mut s), ["red", "blue", "green", "yellow"]);
assert_eq!(s.execute("file.printPreview", json!({"page": 2})).unwrap()["page"]["range"], "C1:D2");
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(page_ranges(&mut s), down);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(page_ranges(&mut s), over);
// Anything but the two orders is refused and changes nothing, even next to a good value.
for bad in [json!("sideways"), json!("OverThenDown"), json!(""), json!(2), json!(null), json!(true), json!(["downThenOver"])] {
let e = s.execute("pageLayout.pageSetup", json!({"pageOrder": bad, "orientation": "landscape"})).unwrap_err();
assert!(e.to_string().contains("pageOrder"), "{bad}: {e}");
}
let pr = &s.doc().unwrap().wb.active().unwrap().print;
assert_eq!((pr.page_order, pr.orientation), (PageOrder::OverThenDown, Orientation::Portrait));
assert!(s.take_ui_requests().is_empty(), "a programmatic call never opens a dialog");
// First-page and odd/even headers go by the printed sequence too.
s.execute(
"pageLayout.headerFooter",
json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}),
)
.unwrap();
let pages = pdf_pages(&mut s);
for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "blue"), ("Odd 3", "green"), ("Even 4", "yellow")]) {
assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}");
}
s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap();
assert_eq!(page_ranges(&mut s), down);
let pages = pdf_pages(&mut s);
for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "green"), ("Odd 3", "blue"), ("Even 4", "yellow")]) {
assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}");
}
}
#[test]
fn page_order_without_breaks_and_with_print_titles() {
let mut s = s();
// Every cell shows its own address: 60 rows and 15 columns are two pages tall and two wide.
let rows: Vec<Vec<Json>> = (1..=60).map(|r| (0..15).map(|c| json!(format!("{}{r}", gridcraft_core::col_to_letters(c)))).collect()).collect();
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
for titles in [false, true] {
if titles {
s.execute("pageLayout.pageSetup", json!({"titleRows": "1:1", "titleCols": "A:A"})).unwrap();
}
s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap();
let down = page_ranges(&mut s);
if !titles {
assert_eq!(down, ["A1:J45", "A46:J60", "K1:O45", "K46:O60"]);
}
s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
let over = page_ranges(&mut s);
assert_eq!(over, [down[0].clone(), down[2].clone(), down[1].clone(), down[3].clone()], "titles: {titles}");
// Each printed page starts with its own top-left cell; the title row and column (A1 is in
// both) repeat on every page in either order.
for (order, ranges) in [("overThenDown", &over), ("downThenOver", &down)] {
s.execute("pageLayout.pageSetup", json!({"pageOrder": order})).unwrap();
for (i, (page, range)) in pdf_pages(&mut s).iter().zip(ranges.iter()).enumerate() {
let top_left = range.split(':').next().unwrap();
assert!(page.contains(&format!("({top_left}) Tj")), "{order} page {}: no {top_left}", i + 1);
assert_eq!(page.contains("(A1) Tj"), titles || i == 0, "{order} page {}, titles: {titles}", i + 1);
}
}
}
}
#[test]
fn first_and_even_pages_print_their_own_headers() {
let mut s = s();
@@ -174,6 +329,14 @@ fn first_and_even_pages_print_their_own_headers() {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
/// The y position of the first `(text) Tj` in a PDF (PDF space: larger is higher on the page).
fn text_y(pdf: &[u8], text: &str) -> Option<f32> {
let s = String::from_utf8_lossy(pdf);
let before = &s[..s.find(&format!("({text}) Tj"))?];
let tm = before.rfind(" Tm")?;
before[..tm].rsplit([' ', '\n']).next()?.parse().ok()
}
/// The x position and font size of the first `(text) Tj` in a PDF.
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
let s = String::from_utf8_lossy(pdf);
@@ -473,6 +636,11 @@ fn hostile_settings_never_panic_and_cap_pages() {
assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64);
let r = s.execute("file.exportPdf", json!({})).unwrap();
assert_eq!(r["pages"], MAX_PAGES);
// Over, then down stops at the cap as well, along the first row of pages.
s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
let pv = s.execute("file.printPreview", json!({})).unwrap();
assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64);
assert_eq!(pv["pageList"][1]["rows"], pv["pageList"][0]["rows"], "the second page is beside the first");
s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 1, "titleRows": "1:1048576", "titleCols": "A:XFD", "headings": true}))
.unwrap();
let pv = s.execute("file.printPreview", json!({})).unwrap();
@@ -565,3 +733,129 @@ fn rotated_wrapped_text_prints_rotated() {
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
}
#[test]
fn checkbox_cells_print_as_boxes_not_true_false() {
// #2972: a checkbox cell printed its value as TRUE or FALSE.
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
}
#[test]
fn an_equation_without_a_layout_prints_its_source_one_line_per_line() {
use super::objects::{MAX_FALLBACK_LINES, fallback_lines};
let mut s = s();
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
// A file can carry a source the layout engine refuses: two lines here, in a frame 100 px
// (75 pt) tall, which holds six 12 pt lines.
let with_source = |text: &str| {
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
for x in sh.shapes.iter_mut() {
x.text = text.into();
x.anchor.height = 100.0;
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
pdf_of(&mut t, json!({})).0
};
let pdf = with_source("frac{\nsecond line");
assert!(contains(&pdf, "(frac{) Tj"), "the first line is its own text run");
assert!(contains(&pdf, "(second line) Tj"), "and so is the second");
assert!(!contains(&pdf, "(frac{?second line) Tj"), "the newline is not printed as ?");
let (y1, y2) = (text_y(&pdf, "frac{").unwrap(), text_y(&pdf, "second line").unwrap());
assert!(y1 > y2 && y1 - y2 < 13.0, "the second line sits one line below the first: {y1} {y2}");
// Lines the frame cannot hold are not printed.
let pdf = with_source(&"l\n".repeat(10_000));
assert_eq!(String::from_utf8_lossy(&pdf).matches("(l) Tj").count(), 6);
// Whatever the frame, the count is capped; a frame too low for one line still shows the first.
let tall = "l\n".repeat(10_000);
assert_eq!(fallback_lines(&tall, f32::INFINITY, 12.0).count(), MAX_FALLBACK_LINES);
assert_eq!(fallback_lines(&tall, 1.0e9, 12.0).count(), MAX_FALLBACK_LINES);
assert_eq!(fallback_lines("a\nb\nc", 30.0, 12.0).count(), 2);
assert_eq!(fallback_lines("a\nb", 0.0, 12.0).count(), 1);
assert_eq!(fallback_lines("a\nb", f32::NAN, 12.0).count(), 1);
}
#[test]
fn the_fallback_text_is_clipped_to_the_equation_frame() {
// A long line of a source that does not lay out, in a narrow frame (40 by 100 px, 30 by
// 75 pt): the lines are drawn inside a clip of the frame, nested in the block's, so they
// cannot run over neighbours, and the clip is lifted again before the next object (a text
// box below the equation in the same block) is drawn.
let mut s = s();
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
s.execute("insert.textBox", json!({"at": "B12", "text": "later box"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
for x in sh.shapes.iter_mut().filter(|x| x.kind == gridcraft_model::ShapeKind::Equation) {
x.text = format!("frac{{{}", "l".repeat(200));
x.anchor.width = 40.0;
x.anchor.height = 100.0;
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let (pdf, _) = pdf_of(&mut t, json!({}));
let text = String::from_utf8_lossy(&pdf);
let clip = text.find("30 75 re W n").expect("a clip the size of the frame");
let run = text.find("(frac{llll").expect("the source line");
let later = text.find("(later box) Tj").expect("the text box after the equation");
assert!(clip < run && run < later, "clip, then the text, then the later object");
// The net number of saved graphics states (`q` less `Q`) before an operator.
let depth = |at: usize| text[..at].lines().filter(|l| *l == "q").count() as i64 - text[..at].lines().filter(|l| *l == "Q").count() as i64;
assert_eq!(depth(run), depth(clip), "the text is drawn under the frame's clip");
assert_eq!(depth(later), depth(clip) - 1, "the later object is drawn with the frame's state restored");
}
#[test]
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
fn an_equation_source_of_a_megabyte_prints_a_bounded_excerpt() {
// A file can carry a source far past `MAX_SOURCE_BYTES` (the drawing part may be 512 MiB). It
// never lays out, so the fallback prints the first 4,096 bytes and does no work on the rest:
// the PDF holds one run of that length, not the megabyte, and export takes bounded time.
let mut s = s();
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
for x in sh.shapes.iter_mut() {
x.text = "l".repeat(1 << 20);
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let start = std::time::Instant::now();
let (pdf, _) = pdf_of(&mut t, json!({}));
let took = start.elapsed();
let text = String::from_utf8_lossy(&pdf);
let longest = text.split(|c| c != 'l').map(str::len).max().unwrap_or(0);
assert_eq!(longest, gridcraft_math::MAX_SOURCE_BYTES, "the run is the excerpt and no more");
assert!(pdf.len() < 64 * 1024, "the PDF holds the excerpt, not the megabyte: {} bytes", pdf.len());
assert!(took < std::time::Duration::from_secs(5), "{took:?}");
}
#[test]
fn equations_print_with_the_symbol_font() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
s.execute("insert.equation", json!({"latex": "\\sum_{i=1}^{n} \\alpha_i \\le \\infty \\pm \\sqrt{x} \\times \\partial y", "cell": "B2"}))
.unwrap();
s.execute("insert.equation", json!({"latex": "\\mathbb{R} \\oplus \\text{\u{4E2D}}", "cell": "B8"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(contains(&pdf, "/BaseFont /Symbol >>"), "maths symbols use the Symbol font");
assert!(contains(&pdf, "/BaseFont /Times-Italic"), "variables are italic");
// The summation (0xE5) and alpha (0x61) codes in Symbol.
assert!(contains(&pdf, "(\\345) Tj") && contains(&pdf, "(a) Tj"));
// A glyph no standard font has is a [?] box.
assert!(contains(&pdf, "(?) Tj"));
}
+137
View File
@@ -0,0 +1,137 @@
//! Python in cells: making and editing Python cells, and their return type. This build stores
//! the code and the last result a file cached; it doesn't run Python, so a cell with new or
//! changed code shows `#BLOCKED!`.
use std::sync::Arc;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{Cell, PyCode, python::MAX_FORMULA_CHARS};
use serde_json::{Value as Json, json};
use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, target_sheet};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"python.set",
"Set Python Code",
[],
None,
"{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)",
has_doc,
set
),
cmd!(
"python.output",
"Python Output",
[],
Some("Ctrl+Alt+Shift+M"),
"{cell?, mode?: object|value} sets the return type of a Python cell (default: the active cell): `object` (a Python object) or `value` (an Excel value). Without `mode` it switches to the other one. The cell keeps its code; a cached result of the old return type goes, as with `python.set`, so the cell shows #BLOCKED! until it runs",
has_doc,
output
),
]
}
fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
Ok((sheet, at))
}
fn existing(s: &Session, sheet: usize, at: CellRef) -> Result<Option<PyCode>> {
Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned())
}
fn output_mode(cmd: &str, p: &Json, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::String(_)) => {}
Some(_) => return Err(bad(cmd, format!("`{key}` is `object` or `value`"))),
}
match str_param(p, key) {
None => Ok(None),
Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)),
Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)),
Some(m) => Err(bad(cmd, format!("`{key}` is `object` or `value`, not `{m}`"))),
}
}
fn set(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, at) = target_cell(s, p)?;
let code = match p.get("code") {
Some(Json::String(c)) => c.as_str(),
Some(_) => return Err(bad("python.set", "`code` must be text")),
None => return Err(bad("python.set", "missing `code`")),
};
let mode = output_mode("python.set", p, "output")?;
let old = existing(s, sheet, at)?;
let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false);
store(s, "python.set", sheet, at, PyCode { object, ..PyCode::new(code) }, old)?;
let d = s.doc()?;
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)}))
}
/// Writes `py` into the cell, which held `old`: what `python.set` and `python.output` share. The
/// code must fit in one cell and a table header takes none; a locked cell on a protected sheet is
/// refused by the edit itself.
fn store(s: &mut Session, cmd: &str, sheet: usize, at: CellRef, mut py: PyCode, old: Option<PyCode>) -> Result<()> {
if !py.fits() {
return Err(bad(
cmd,
format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"),
));
}
// A table header is the column's name, never a formula or code (as in Excel).
let header =
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.header_row && t.range.start.row == at.row && t.range.contains(at)));
if header {
return Err(EngineError::Other(format!("{} is a table header; Python cells can't go in a table's header row", at.a1())));
}
// Unchanged code with the same return type keeps its result (and its value); new code, or a
// new return type, has none until it runs: a result of the other type would show a value and
// preview that don't belong to the cell.
let keep = old.filter(|o| o.code == py.code && o.object == py.object);
let value = match &keep {
Some(o) => {
py.output = o.output.clone();
py.stale = o.stale;
None
}
None => Some(gridcraft_model::python::BLOCKED),
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(at);
let value = value.unwrap_or_else(|| sh.cell(at).map(|c| c.value.clone()).unwrap_or(Value::Empty));
// A spill the cell had (a formula's, or the old result's) is left to the recalculation,
// which removes it and recalculates what read it.
sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) });
cx.touch(sheet, at);
Ok(())
})
}
/// `python.output`: the cell's return type. The code stays; a cached result belongs to the old
/// return type, so it goes, as with `python.set` and a new return type: the cell shows
/// `#BLOCKED!` until it runs, and its old spill goes with its readers recalculated.
fn output(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, at) = target_cell(s, p)?;
let Some(old) = existing(s, sheet, at)? else {
return Err(EngineError::Other(format!("{} isn't a Python cell, so it has no Python output to change", at.a1())));
};
let object = output_mode("python.output", p, "mode")?.unwrap_or(!old.object);
let mode = |object: bool| if object { "object" } else { "value" };
if object != old.object {
let py = PyCode { code: old.code.clone(), object, output: None, stale: false };
store(s, "python.output", sheet, at, py, Some(old))?;
}
let d = s.doc()?;
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
Ok(json!({"cell": at.a1(), "output": mode(object), "value": crate::cmd::inspect::value_json(&shown)}))
}
+1
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Allow Edit Ranges…",
menu: &["Review", "Protect"],
shortcut: None,
shortcut_mac: None,
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
enabled: has_doc,
run: allow_edit_ranges,
+25 -46
View File
@@ -141,14 +141,7 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
};
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
// re-fit after the shift measures against the grid the object actually lands on.
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
.charts
.iter()
.map(|o| &o.anchor)
.chain(sh.images.iter().map(|o| &o.anchor))
.chain(sh.shapes.iter().map(|o| &o.anchor))
.map(|a| anchor_corner(sh, a))
.collect();
let obj_corners: Vec<(u32, u32, f32, f32)> = sh.object_anchors().map(|a| anchor_corner(sh, a)).collect();
// Line info.
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
let old = std::mem::take(lines);
@@ -193,22 +186,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
// against the post-shift grid first (the re-fit needs its sizes), then written back.
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
let base = new_charts.len() + new_images.len();
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
o.anchor = a;
}
for (o, a) in sh.images.iter_mut().zip(new_images) {
o.anchor = a;
}
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
o.anchor = a;
// Floating objects follow their anchor lines per their [`AnchorMode`]. New anchors are
// computed against the post-shift grid first (the re-fit needs its sizes), then written back
// in the same `object_anchors` order the corners were snapshotted in.
let new_anchors: Vec<Anchor> =
sh.object_anchors().zip(&obj_corners).map(|(a, &corner)| shift_anchor(sh, *a, corner, axis, at, &map_line)).collect();
for (a, n) in sh.object_anchors_mut().zip(new_anchors) {
*a = n;
}
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
Some(c) => {
@@ -231,9 +215,12 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
}
}
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
super::edit::rewrite_all_formulas(wb, target, e);
/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
/// source for a structural edit on `target`.
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
cx.edits.push((target.to_string(), e.clone()));
let wb = &mut cx.wb;
super::edit::rewrite_all_formulas(wb, target, e)?;
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
let Some(sh) = wb.sheet_mut(si) else { continue };
@@ -243,15 +230,6 @@ pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit
Err(_) => text.to_string(),
}
};
for ch in sh.charts.iter_mut() {
// The source block moves with its cells; one that was deleted is dropped.
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
}
for sp in sh.sparklines.iter_mut() {
sp.source = fix(&sp.source);
}
@@ -268,6 +246,7 @@ pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit
}
}
}
Ok(())
}
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
@@ -296,7 +275,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
@@ -325,7 +304,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
// New lines take the format of the line before (Excel's "Format Same As Above").
inherit_line_format(sh, axis, at, count);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
rewrite(cx, &name, &Edit::Insert { axis, at, count })?;
cx.structural = true;
Ok(Json::Null)
})?;
@@ -390,7 +369,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
shift_sheet_features(sh, axis, at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
rewrite(cx, &name, &Edit::Delete { axis, at, count })?;
}
cx.structural = true;
Ok(Json::Null)
@@ -442,7 +421,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
sh.cells.shift_cols(before, count as i64);
shift_sheet_features(sh, Axis::Cols, before, count, true);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count })?;
let mut touched = Vec::new();
{
@@ -480,14 +459,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
for cell in touched {
cx.touch(sheet, cell);
}
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination })?;
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(delete_at + count, -(count as i64));
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count })?;
*cx.sel = crate::Selection {
active: CellRef::new(active_row, final_start),
anchor: CellRef::new(active_row, final_start),
@@ -518,8 +497,8 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
} else {
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
};
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
if sh.cells.iter_range(doomed).any(|(_, x)| x.has_content()) {
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
let right = shift == "right";
@@ -532,7 +511,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
shift_cell_features(sh, &e);
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e)?;
cx.structural = true;
Ok(Json::Null)
})?;
@@ -567,7 +546,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
shift_cell_features(sh, &e);
}
// References into the deleted cells become #REF!; the rest move.
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e)?;
cx.structural = true;
Ok(Json::Null)
})?;
+21 -10
View File
@@ -63,6 +63,9 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order))?;
// Chart series into the deleted sheet become #REF!, like formulas.
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order));
cx.wb.names.retain(|n| n.scope != Some(i));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
@@ -116,17 +119,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
let fix = |t: &str| {
gridcraft_formula::parse(t)
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)))
.unwrap_or_else(|_| t.to_string())
};
for ch in sh.charts.iter_mut() {
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
&& sheet.eq_ignore_ascii_case(&old)
@@ -135,11 +128,13 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name));
for n in cx.wb.names.iter_mut() {
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
}
}
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name))?;
cx.structural = true;
Ok(Json::Null)
})
@@ -157,6 +152,8 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let Some(src) = cx.wb.sheets.get(i).cloned() else { return Err(bad("sheet.move", "no such sheet")) };
if copy {
let mut sh = (*src).clone();
// The copy is a sheet of its own, not the one it was copied from.
sh.uid = gridcraft_model::SheetUid::fresh();
let base = sh.name.clone();
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
let name = (2..)
@@ -170,6 +167,20 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sh.name = name;
sh.tables.clear(); // table names must be unique
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
// the largest id).
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
let ids = cx.wb.free_object_ids(count);
if ids.len() < count {
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
}
let mut ids = ids.into_iter();
let mut fresh = || ids.next().unwrap_or(u32::MAX);
sh.charts.iter_mut().for_each(|o| o.id = fresh());
sh.images.iter_mut().for_each(|o| o.id = fresh());
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
+57 -28
View File
@@ -36,20 +36,25 @@ use gridcraft_core::split_sheet;
mod evolutionary;
mod lp;
mod model;
mod model_range;
mod nlp;
mod problem;
mod reports;
mod sensitivity;
use evolutionary::{EvoEnd, evolutionary};
use lp::{Outcome, Sense, branch_and_bound, linearize, solve_lp};
pub use model::saved_model;
use lp::{Outcome, SimplexProgram, branch_and_bound, simplex_program, solve_lp};
use model::{Goal, Method, Model, model_params, parse_model, save_model};
pub use model::{saved_model, sensitivity_applies, sensitivity_status};
use model_range::{load_model_cells, reset_model, save_model_cells};
use nlp::{nlp, project};
use problem::{Evaluator, as_num, build_problem};
use reports::{ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports};
use reports::{NotOptimal, ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports};
const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport";
const SAVE_CMD: &str = "data.solverSaveModel";
const LOAD_CMD: &str = "data.solverLoadModel";
/// Excel's limit on decision variables.
const MAX_VARS: usize = 200;
/// Most scalar constraints (after expanding ranges).
@@ -74,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Solver…",
["Data", "Analysis"],
None,
"{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).",
"{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden names, solver_* as Excel names them, with the options; the evaluation limit and generations without improvement, which Excel lacks, as gridcraft_solver_evals and gridcraft_solver_stall), maxEvaluations?: 20000 (at most 200000), integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|sensitivity|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names], skippedReports?: [{report: \"sensitivity\", reason}] (a Sensitivity report left out because the run did not reach an optimum or the values are not one; the solve and the other reports still apply)}. No `objective` → {model: the saved model} (the menu opens the dialog).",
has_doc,
run
),
@@ -83,10 +88,37 @@ pub fn specs() -> Vec<CommandSpec> {
"Solver Reports",
[],
None,
"{reports: [answer|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names]}. Writes Answer and Limits reports on new sheets before the model's sheet; Limits needs Simplex LP or GRG Nonlinear and no integer constraints. data.solver's result can be passed as is.",
"{reports: [answer|sensitivity|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), skipNotOptimal?: bool (default false: a Sensitivity report for values that are not optimal refuses the call; true leaves it out and writes the others), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names], skippedReports?: [{report, reason}]}. Writes Answer, Sensitivity and Limits reports on new sheets before the model's sheet; Sensitivity and Limits need Simplex LP or GRG Nonlinear and no integer constraints, and Sensitivity a Max or Min objective. data.solver's result can be passed as is.",
has_doc,
report
),
cmd!(
"data.solverSaveModel",
"Save Model",
[],
None,
"{to: \"H1\" (the first cell, on the model's sheet), model?: data.solver params (default the sheet's saved model), overwrite?: false} → {range}. Writes the model down a column as formulas: =MAX(objective), =MIN(objective) or =objective=value; =COUNT(variables); one =AND(relation) per constraint (=AND(x=INT(x)) for integer, =AND((x=0)+(x=1)) for binary), which shows TRUE while it holds; and the options as an array constant {method (1 GRG Nonlinear, 2 Simplex LP, 3 Evolutionary), non-negative (1 yes, 2 no), population size, mutation rate, random seed, require bounds (1 yes, 2 no), convergence, generations without improvement, maximum evaluations, integer optimality %}, which spills into the cells to its right. Refused, with the sheet unchanged, when the model's cells, content, another formula's spilled values, merged cells or a table are in the way of the options' spill, or it would run off the sheet, and when the column would cover another formula's spilled values. A column that holds a value or a formula (a model saved there before included) is replaced only with overwrite: true; without it the command is refused (\"already holds data\"), as Excel asks first. One undo step.",
has_doc,
save_model_cells
),
cmd!(
"data.solverLoadModel",
"Load Model",
[],
None,
"{from: \"H1:H6\" (a range on the active sheet; one cell reads down to the first cell without a formula; a range needs a formula in every cell up to its last formula, and may end in empty cells)} → {model}. Reads a model written by Save Model and makes it the sheet's Solver model. The model must end with its options row ({…}), which Save Model writes last; a model cut short (a cell before the options row without a formula, or the options row cleared) is refused and the sheet's model stays as it was.",
has_doc,
load_model_cells
),
cmd!(
"data.solverReset",
"Reset All",
[],
None,
"{} → {removed, masked}. Removes the active sheet's Solver model. A model read from workbook-level names stays for the other sheets and is masked on this one by an empty sheet-level solver_opt (masked: true).",
has_doc,
reset_model
),
]
}
@@ -115,24 +147,13 @@ fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bound
Outcome::Infeasible
} else {
match m.method {
Method::Simplex => match linearize(ev, x0) {
Method::Simplex => match simplex_program(ev, goal, x0) {
Err("nonlinear") | Err("error") => {
not_linear = true;
Outcome::Infeasible
}
Err(_) => Outcome::Limit,
Ok(((cf, f_const), lin)) => {
let mut rows: Vec<(Vec<f64>, Sense, f64)> = lin.iter().zip(&ev.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
let c: Vec<f64> = match goal {
Goal::Max => cf.iter().map(|v| -v).collect(),
Goal::Min => cf.clone(),
Goal::Value(v) => {
rows.push((cf.clone(), Sense::Eq, v - f_const));
vec![0.0; cf.len()]
}
};
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
let mut work = LP_WORK.max(size);
Ok(SimplexProgram { c, rows, mut work, .. }) => {
let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work);
if any_int { branch_and_bound(lo, hi, integer, MAX_NODES_LP, gap, &mut relax).0 } else { relax(lo, hi) }
}
@@ -182,16 +203,19 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
return Ok(json!({"model": model}));
}
let model = parse_model(s, &params)?;
let budget = params
.get("maxEvaluations")
.and_then(Json::as_f64)
.filter(|v| v.is_finite() && *v >= 1.0)
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
let gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0);
let (budget, gap) = (model.max_evals, model.gap);
let keep = bool_param(&params, "keep").unwrap_or(true);
let save = bool_param(&params, "saveModel").unwrap_or(true);
let reports = report_kinds(CMD, &params)?;
let prob = build_problem(&model);
// A Sensitivity report the model has none of (integer, Evolutionary, Value Of) fails the
// call, so it is refused before the solve runs rather than after it.
if let Some(kinds) = &reports
&& kinds.contains(&reports::ReportKind::Sensitivity)
&& let Some(why) = sensitivity::sensitivity_refusal(&model, &prob)
{
return Err(EngineError::Other(why.into()));
}
let d = s.doc()?;
let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?;
let x0: Vec<f64> = model.vars.iter().map(|c| as_num(&sh.value(*c))).map(|v| if v.is_finite() { v } else { 0.0 }).collect();
@@ -251,12 +275,14 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
}
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
let changed = keep && solution.is_some();
let built = match (&reports, &solution) {
// A Sensitivity report the solution doesn't support is left out (with its reason), so the
// solve and the other reports still apply.
let (built, skipped) = match (&reports, &solution) {
(Some(kinds), Some(x)) => {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
prepare_reports(s.doc()?, kinds, &inp)?
prepare_reports(s.doc()?, kinds, &inp, NotOptimal::Skip { optimum: sensitivity_status(status) })?
}
_ => Vec::new(),
_ => (Vec::new(), Vec::new()),
};
let count = built.len();
let before = s.doc()?.wb.clone();
@@ -277,7 +303,7 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(sheet)?;
for (c, v) in model.vars.iter().zip(x) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style, py: None });
}
for c in &model.vars {
cx.touch(sheet + count, *c);
@@ -299,6 +325,9 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
if reports.is_some() && solution.is_some() {
out["reports"] = json!(names);
}
if !skipped.is_empty() {
out["skippedReports"] = json!(skipped);
}
Ok(out)
}
+89 -14
View File
@@ -1,10 +1,14 @@
use super::model::Goal;
use super::problem::Evaluator;
use super::{CellRef, EPS};
use super::{CellRef, EPS, LP_WORK};
// ---------------------------------------------------------------- evaluation
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
/// A row of an LP: coefficients, sense and right side.
pub(super) type LpRow = (Vec<f64>, Sense, f64);
/// Objective (gradient, constant) and per-row (gradient, constant).
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
@@ -34,9 +38,10 @@ pub(super) enum Outcome {
Limit,
}
struct Map {
var: usize,
sign: f64,
/// A column of the simplex tableau standing for a variable: `x_var = offset + Σ sign·y`.
pub(super) struct Map {
pub(super) var: usize,
pub(super) sign: f64,
}
struct VariableMap {
@@ -45,6 +50,23 @@ struct VariableMap {
bound_rows: Vec<BoundRow>,
}
/// The final tableau of an optimal LP, for the Sensitivity report.
pub(super) struct FinalTableau {
/// Rows `B⁻¹A | B⁻¹b`.
pub(super) t: Vec<Vec<f64>>,
pub(super) basis: Vec<usize>,
/// The phase-2 reduced costs.
pub(super) obj: Vec<f64>,
pub(super) width: usize,
/// The first artificial column.
pub(super) first_art: usize,
pub(super) cols: Vec<Map>,
/// Per input row: the column that started as its unit column, and whether the row was
/// negated to make its right side non-negative.
pub(super) ident: Vec<usize>,
pub(super) flipped: Vec<bool>,
}
struct Tableau {
rows: Vec<Vec<f64>>,
basis: Vec<usize>,
@@ -82,7 +104,9 @@ fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
VariableMap { columns, offset, bound_rows }
}
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
/// The rows over the tableau's columns, each with a non-negative right side, and per row
/// whether it was negated to get there.
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> (Vec<LpRow>, Vec<bool>) {
let width = columns.len();
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
for (coefficients, sense, rhs) in rows {
@@ -105,7 +129,8 @@ fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, co
transformed.push((row, sense, rhs));
}
// Rows with a negative right side are negated so every right side is >= 0.
transformed
let flipped = transformed.iter().map(|row| row.2 < 0.0).collect();
let normalized = transformed
.into_iter()
.map(|(row, sense, rhs)| {
if rhs < 0.0 {
@@ -119,7 +144,8 @@ fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, co
(row, sense, rhs)
}
})
.collect()
.collect();
(normalized, flipped)
}
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
@@ -199,7 +225,7 @@ fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budge
return Err(Outcome::Infeasible);
}
for i in 0..tableau.rows.len() {
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
if tableau.basis.get(i).is_none_or(|basic| *basic < tableau.decision_cols + tableau.slack_cols) {
continue;
}
let pivot_col = tableau
@@ -388,20 +414,33 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
solve_lp_tableau(c, rows, lo, hi, budget).0
}
/// [`solve_lp`], with the final tableau when it is optimal.
pub(super) fn solve_lp_tableau(
c: &[f64],
rows: &[(Vec<f64>, Sense, f64)],
lo: &[f64],
hi: &[f64],
budget: &mut u64,
) -> (Outcome, Option<FinalTableau>) {
if lo.iter().zip(hi).any(|(l, h)| l > h) {
return Outcome::Infeasible;
return (Outcome::Infeasible, None);
}
// x_j = offset + Σ sign·y_k with y ≥ 0.
let mapping = map_variables(c.len(), lo, hi);
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
let ny = cols.len();
// Rows over y: (coefficients, sense, rhs).
let normalized = normalize_rows(rows, extra, &cols, &offset);
let (normalized, flipped) = normalize_rows(rows, extra, &cols, &offset);
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
// Each row's starting basic column is its unit column.
let ident = tableau.basis.clone();
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
return outcome;
return (outcome, None);
}
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
let is_art = |column: usize| column >= ny + n_slack && column < width;
@@ -423,8 +462,8 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
}
let allowed: Vec<bool> = (0..width).map(|j| !is_art(j)).collect();
match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded => return Outcome::Unbounded,
Step::Limit => return (Outcome::Limit, None),
Step::Unbounded => return (Outcome::Unbounded, None),
Step::Optimal => {}
}
let mut y = vec![0.0; width];
@@ -442,7 +481,43 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
}
}
let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum();
Outcome::Solved(x, val, true)
let tableau = FinalTableau { t, basis, obj, width, first_art: ny + n_slack, cols, ident, flipped };
(Outcome::Solved(x, val, true), Some(tableau))
}
/// The LP to minimise for `goal`, from the linearised model: the costs, and the rows
/// (coefficients, sense, right side) in the order of `model_rows`.
pub(super) fn linear_program(goal: Goal, cf: &[f64], f_const: f64, lin: &[(Vec<f64>, f64)], model_rows: &[Row]) -> (Vec<f64>, Vec<LpRow>) {
let mut rows: Vec<LpRow> = lin.iter().zip(model_rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
let c = match goal {
Goal::Max => cf.iter().map(|v| -v).collect(),
Goal::Min => cf.to_vec(),
Goal::Value(v) => {
rows.push((cf.to_vec(), Sense::Eq, v - f_const));
vec![0.0; cf.len()]
}
};
(c, rows)
}
/// The linear program Simplex LP solves: the model linearised at `x0`, its objective
/// coefficients, the costs and rows for the goal, and the work limit for its size.
pub(super) struct SimplexProgram {
pub(super) cf: Vec<f64>,
pub(super) c: Vec<f64>,
pub(super) rows: Vec<LpRow>,
pub(super) work: u64,
}
/// Builds the [`SimplexProgram`] at `x0`. The solve and the Sensitivity report both build it
/// here from the start values, so the report's solve repeats the solve's pivots and returns the
/// same vertex: linearising at another point changes the coefficients in their last bits, and
/// that can break a tie between pivots the other way.
pub(super) fn simplex_program(ev: &mut Evaluator, goal: Goal, x0: &[f64]) -> std::result::Result<SimplexProgram, &'static str> {
let ((cf, f_const), lin) = linearize(ev, x0)?;
let (c, rows) = linear_program(goal, &cf, f_const, &lin, &ev.rows);
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
Ok(SimplexProgram { cf, c, rows, work: LP_WORK.max(size) })
}
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
+307 -32
View File
@@ -1,6 +1,6 @@
use super::{
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
bad, bool_param, f64_param, json, split_sheet, str_param,
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
};
// ---------------------------------------------------------------- model
@@ -128,6 +128,10 @@ pub(super) struct Model {
pub(super) non_negative: bool,
pub(super) method: Method,
pub(super) evo: EvoOptions,
/// The evaluation limit (in place of Excel's iterations and time limits).
pub(super) max_evals: u64,
/// Integer Optimality, as a fraction.
pub(super) gap: f64,
}
/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`.
@@ -259,7 +263,8 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
}
let mut seen = std::collections::HashSet::new();
vars.retain(|c| seen.insert(*c));
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) {
// Python cells count as formulas: a solution written over one would lose its code.
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into()));
}
let mut cons = Vec::new();
@@ -288,13 +293,50 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
"evolutionary" | "evo" | "genetic" => Method::Evolutionary,
m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))),
};
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) })
let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite());
let max_evals = num("maxEvaluations").filter(|v| *v >= 1.0).map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
// Integer Optimality is in percent: "5" and "5%" both mean 5 % (number parsing would read
// "5%" as 0.05).
let percent = p
.get("integerOptimality")
.and_then(Json::as_f64)
.or_else(|| str_param(p, "integerOptimality").and_then(|t| num_text(t.trim().trim_end_matches('%'))))
.filter(|v| v.is_finite());
let gap = percent.map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0);
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p), max_evals, gap })
}
/// Whether a `data.solver` result status is a solution the Sensitivity report can describe: an
/// optimum (Simplex LP) or a converged local optimum (GRG Nonlinear). A run that stopped at its
/// evaluation limit, or an Evolutionary run, has none.
pub fn sensitivity_status(status: &str) -> bool {
matches!(status, "optimal" | "converged")
}
/// Whether `data.solver` params (such as the `model` of its result) describe a model that can
/// have a Sensitivity report: Simplex LP or GRG Nonlinear, a Max or Min objective and no
/// integer or binary constraints.
pub fn sensitivity_applies(p: &Json) -> bool {
let method = str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "");
let goal = str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase();
let integer = p.get("constraints").and_then(Json::as_array).is_some_and(|list| {
list.iter().any(|c| {
let rel = match c {
Json::String(t) => split_constraint(t).map(|(_, rel, _)| rel),
Json::Object(_) => str_param(c, "op").or_else(|| str_param(c, "relation")).and_then(Rel::parse),
_ => None,
};
matches!(rel, Some(Rel::Int | Rel::Bin))
})
});
!integer && !matches!(method.as_str(), "evolutionary" | "evo" | "genetic") && !matches!(goal.as_str(), "value" | "valueof" | "value of")
}
// ---------------------------------------------------------------- saved model
fn ref_text(sheet_name: &str, cells: &str) -> String {
let q = crate::pivot::quote_sheet(sheet_name);
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default();
cells
.split(',')
.map(str::trim)
@@ -302,16 +344,49 @@ fn ref_text(sheet_name: &str, cells: &str) -> String {
.filter_map(|part| {
let r = RangeRef::parse(split_sheet(part).1.trim())?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) })
Some(if r.is_single() { format!("{q}{}", abs(r.start)) } else { format!("{q}{}:{}", abs(r.start), abs(r.end)) })
})
.collect::<Vec<_>>()
.join(",")
}
fn ref_text(sheet_name: &str, cells: &str) -> String {
abs_text(Some(sheet_name), cells)
}
/// The evaluation limit and the generations without improvement have no Excel equivalent:
/// Excel's `solver_itr` counts iterations (100 by default) and `solver_mni` seconds, so a
/// workbook from Excel would cut GridCraft's search short. They are saved under GridCraft's own
/// names, which Excel ignores.
const EVALS_NAME: &str = "gridcraft_solver_evals";
const STALL_NAME: &str = "gridcraft_solver_stall";
/// The names of a saved model after `solver_`, as Excel's Solver writes them (GridCraft writes
/// some of them). Saving a model and Reset All remove these and no other `solver_` names.
const SOLVER_KEYS: [&str; 28] = [
"adj", "cvg", "drv", "eng", "est", "itr", "lin", "mip", "mni", "mrt", "msl", "neg", "nod", "num", "nwt", "opt", "pre", "rbv", "rlx", "rsd",
"scl", "sho", "ssz", "tim", "tol", "typ", "val", "ver",
];
/// Whether `name` is one of the names of a saved model: Excel's Solver names (`solver_opt`,
/// `solver_lhs1` and so on) and GridCraft's own. A user's name such as `solver_budget` is not.
pub(super) fn is_model_name(name: &str) -> bool {
let name = name.to_ascii_lowercase();
if name == EVALS_NAME || name == STALL_NAME {
return true;
}
let Some(key) = name.strip_prefix("solver_") else { return false };
if SOLVER_KEYS.contains(&key) {
return true;
}
// One name per constraint: solver_lhs1, solver_rel1, solver_rhs1, …
["lhs", "rel", "rhs"].iter().filter_map(|p| key.strip_prefix(p)).any(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
/// The model as hidden names scoped to its sheet (replacing any saved before).
pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return };
wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_")));
wb.names.retain(|n| !(n.scope == Some(m.sheet) && is_model_name(&n.name)));
let mut put = |name: String, formula: String| {
wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true });
};
@@ -349,26 +424,31 @@ pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
Method::Evolutionary => "3",
};
put("solver_eng".into(), eng.into());
if m.method == Method::Evolutionary {
put("solver_ssz".into(), m.evo.population.to_string());
put("solver_mrt".into(), m.evo.mutation.to_string());
put("solver_rsd".into(), m.evo.seed.to_string());
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
}
put(EVALS_NAME.into(), m.max_evals.to_string());
put("solver_tol".into(), m.gap.to_string());
// The Evolutionary options are kept whatever the method, as Solver Options sets them, so
// switching methods doesn't lose them.
put("solver_ssz".into(), m.evo.population.to_string());
put("solver_mrt".into(), m.evo.mutation.to_string());
put("solver_rsd".into(), m.evo.seed.to_string());
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
put("solver_cvg".into(), m.evo.convergence.to_string());
put(STALL_NAME.into(), m.evo.stall.to_string());
put("solver_ver".into(), "3".into());
}
/// The saved model of a sheet as `data.solver` params, if any.
///
/// A sheet's own (sheet-level) model wins over one saved as workbook-level names, and its names
/// are read alone, so the two never mix. A sheet-level `solver_opt` of `""` (written by Reset
/// All over a workbook-level model) means the sheet has no model.
pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
let get = |n: &str| {
wb.names
.iter()
.find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none()))
.map(|d| d.formula.trim().trim_start_matches('=').to_string())
};
let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",") };
let objective = local(get("solver_opt")?);
if objective.is_empty() {
if objective.trim_matches('"').trim().is_empty() {
return None;
}
let goal = match get("solver_typ").as_deref().map(str::trim) {
@@ -407,11 +487,21 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
"nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"),
"method": method,
});
for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] {
for (name, key) in [
("solver_ssz", "populationSize"),
("solver_mrt", "mutationRate"),
("solver_rsd", "randomSeed"),
("solver_cvg", "convergence"),
(STALL_NAME, "generationsWithoutImprovement"),
(EVALS_NAME, "maxEvaluations"),
] {
if let Some(v) = get(name).and_then(|v| num_text(&v)) {
model[key] = json!(v);
model[key] = number(v);
}
}
if let Some(v) = get("solver_tol").and_then(|v| num_text(&v)) {
model["integerOptimality"] = number(percent(v));
}
if let Some(v) = get("solver_rbv") {
model["requireBounds"] = json!(v.trim() != "2");
}
@@ -448,15 +538,200 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
"constraints": constraints,
"nonNegative": m.non_negative,
"method": method,
"maxEvaluations": m.max_evals,
"integerOptimality": number(percent(m.gap)),
});
if m.method == Method::Evolutionary {
let e = &m.evo;
out["populationSize"] = json!(e.population);
out["mutationRate"] = json!(e.mutation);
out["randomSeed"] = json!(e.seed);
out["convergence"] = json!(e.convergence);
out["generationsWithoutImprovement"] = json!(e.stall);
out["requireBounds"] = json!(e.require_bounds);
}
let e = &m.evo;
out["populationSize"] = json!(e.population);
out["mutationRate"] = json!(e.mutation);
out["randomSeed"] = json!(e.seed);
out["convergence"] = json!(e.convergence);
out["generationsWithoutImprovement"] = json!(e.stall);
out["requireBounds"] = json!(e.require_bounds);
out
}
// ---------------------------------------------------------------- model on cells
/// The cells Save Model writes, top to bottom, as formula text.
pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
let one_area = |t: &str| !t.contains(',');
let mut rows = Vec::with_capacity(m.cons.len() + 3);
let obj = abs_text(None, &m.objective.a1());
rows.push(match m.goal {
Goal::Max => format!("=MAX({obj})"),
Goal::Min => format!("=MIN({obj})"),
Goal::Value(v) => format!("={obj}={}", gridcraft_core::number_to_text(v)),
});
rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
for c in &m.cons {
if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) {
return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
}
let lhs = abs_text(None, &c.lhs_text);
rows.push(match (c.rel, &c.rhs) {
(Rel::Int, _) => format!("=AND({lhs}=INT({lhs}))"),
(Rel::Bin, _) => format!("=AND(({lhs}=0)+({lhs}=1))"),
(rel, Rhs::Cells(_)) => format!("=AND({lhs}{}{})", rel.text(), abs_text(None, &c.rhs_text)),
(rel, Rhs::Const(v)) => format!("=AND({lhs}{}{})", rel.text(), gridcraft_core::number_to_text(*v)),
(rel, Rhs::None) => format!("=AND({lhs}{}0)", rel.text()),
});
}
let e = &m.evo;
let n = gridcraft_core::number_to_text;
let eng = match m.method {
Method::Grg => 1,
Method::Simplex => 2,
Method::Evolutionary => 3,
};
let yes = |b: bool| if b { 1 } else { 2 };
rows.push(format!(
"={{{eng},{},{},{},{},{},{},{},{},{}}}",
yes(m.non_negative),
e.population,
n(e.mutation),
e.seed,
yes(e.require_bounds),
n(e.convergence),
e.stall,
m.max_evals,
n(percent(m.gap))
));
Ok(rows)
}
/// `NAME(inner)` → inner, ignoring case.
fn call_args(t: &str, name: &str) -> Option<String> {
let head = t.get(..name.len() + 1)?;
(head.eq_ignore_ascii_case(&format!("{name}(")) && t.ends_with(')')).then(|| t.get(name.len() + 1..t.len() - 1).map(str::to_string))?
}
/// References without sheet names or `$`.
fn local_refs(t: &str) -> String {
t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
}
fn not_a_model() -> EngineError {
EngineError::Other("The range doesn't hold a Solver model saved with Save Model.".into())
}
/// How many values the options row of Save Model holds at least: method, non-negative,
/// population size, mutation rate, random seed, require bounds, convergence and generations
/// without improvement.
const OPTIONS_FIELDS: usize = 8;
/// The options row of Save Model, `{method, non-negative, …}`, into `model`. Refused when it
/// holds fewer than [`OPTIONS_FIELDS`] values or a value that isn't a number: a row cut short
/// or edited by hand would otherwise load with default options in place of the saved ones.
fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
let incomplete = || {
EngineError::Other(format!(
"The model's options row doesn't hold the {OPTIONS_FIELDS} or more numbers Save Model writes, so the range isn't a complete model saved with Save Model."
))
};
// Every value is checked; only the first ones are kept (the row is one formula, so its
// length is bounded by the formula's).
let mut v: Vec<f64> = Vec::with_capacity(16);
let mut count = 0usize;
for t in list.split([',', ';']) {
let x = num_text(t).ok_or_else(incomplete)?;
count = count.saturating_add(1);
if v.len() < 16 {
v.push(x);
}
}
if count < OPTIONS_FIELDS {
return Err(incomplete());
}
let get = |k: usize| v.get(k).copied();
model["method"] = json!(match get(0).map(|x| x as i64) {
Some(2) => "simplex",
Some(3) => "evolutionary",
_ => "grg",
});
model["nonNegative"] = json!(get(1) != Some(2.0));
for (k, key) in [
(2, "populationSize"),
(3, "mutationRate"),
(4, "randomSeed"),
(6, "convergence"),
(7, "generationsWithoutImprovement"),
(8, "maxEvaluations"),
(9, "integerOptimality"),
] {
if let Some(x) = get(k) {
model[key] = json!(x);
}
}
model["requireBounds"] = json!(get(5) != Some(2.0));
Ok(())
}
/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
fn read_constraint_row(t: String) -> Result<String> {
let t = call_args(&t, "AND").unwrap_or(t);
if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) {
if y.strip_suffix(')') != Some(x) {
return Err(not_a_model());
}
return Ok(format!("{} int", local_refs(x)));
}
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) {
return Ok(format!("{} bin", local_refs(x.0)));
}
let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
let r = r.trim_matches('"');
Ok(match r.to_ascii_lowercase().as_str() {
"integer" => format!("{} int", local_refs(&l)),
"binary" => format!("{} bin", local_refs(&l)),
_ if num_text(r).is_some() => format!("{} {} {r}", local_refs(&l), rel.text()),
_ => format!("{} {} {}", local_refs(&l), rel.text(), local_refs(r)),
})
}
/// Reads formula text written by Save Model back into `data.solver` params.
pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
let body = |t: &str| t.trim().trim_start_matches('=').replace(' ', "");
let first = body(rows.first().ok_or_else(not_a_model)?);
let (goal, objective, value) = if let Some(o) = call_args(&first, "MAX") {
("max", o, 0.0)
} else if let Some(o) = call_args(&first, "MIN") {
("min", o, 0.0)
} else {
let (o, v) = first.split_once('=').ok_or_else(not_a_model)?;
("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
};
let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
let mut model = json!({"objective": local_refs(&objective), "goal": goal, "value": value, "variables": local_refs(&variables)});
let mut constraints = Vec::new();
let rest = rows.get(2..).unwrap_or(&[]);
for (i, row) in rest.iter().enumerate() {
let t = body(row);
if let Some(list) = t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
// The options: the last row.
if i + 1 != rest.len() {
return Err(not_a_model());
}
read_options_row(&mut model, list)?;
break;
}
constraints.push(json!(read_constraint_row(t)?));
}
model["constraints"] = json!(constraints);
Ok(model)
}
/// Integer Optimality as a percent from its stored fraction. The product is rounded to 1E-10 %,
/// so a fraction such as 0.07 reads back as 7 and not 7.000000000000001.
fn percent(fraction: f64) -> f64 {
let p = fraction * 100.0;
let rounded = (p * 1e10).round() / 1e10;
if rounded.is_finite() { rounded } else { p }
}
/// A number read from a hidden name as JSON: a whole number as an integer, so the Solver dialog
/// shows 20000 and not 20000.0.
fn number(v: f64) -> Json {
// Whole numbers below 2^53 are exact as integers.
if v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 { json!(v as i64) } else { json!(v) }
}
+305
View File
@@ -0,0 +1,305 @@
//! Load/Save Model and Reset All: the model written on cells of its sheet, read back from them,
//! and removed from the sheet.
use super::model::{Rhs, is_model_name, model_formulas, parse_model, parse_model_formulas, save_model, saved_model};
use super::{
Cell, CellRef, DefinedName, EngineError, Json, LOAD_CMD, MAX_CONSTRAINTS, RangeRef, Result, SAVE_CMD, Session, Sheet, Value, bad, bool_param,
edit, json, split_sheet, str_param,
};
use crate::cmd::analysis::OVERWRITE_ERROR;
use crate::cmd::cell_param_on;
/// Most cells a model written by Save Model takes: the objective, the variables, the options
/// and one per constraint.
const MAX_MODEL_ROWS: usize = MAX_CONSTRAINTS + 3;
fn is_options_row(formula: &str) -> bool {
formula.trim().trim_start_matches('=').trim_start().starts_with('{')
}
/// The formulas from `start` down: to the first cell without a formula, or through the options
/// row (`{…}`) that ends a model written by Save Model; at most [`MAX_MODEL_ROWS`] cells.
fn formulas_down(sheet: &Sheet, start: CellRef) -> Vec<(CellRef, String)> {
let mut out = Vec::new();
for k in 0..MAX_MODEL_ROWS as u32 {
let Some(c) = start.row.checked_add(k).map(|row| CellRef::new(row, start.col)) else { break };
let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone()) else { break };
let last = is_options_row(&f);
out.push((c, f));
if last {
break;
}
}
out
}
/// The cells of a model written by Save Model that starts at `start` (none if the cells there
/// don't hold one).
fn saved_model_cells(sheet: &Sheet, start: CellRef) -> Vec<CellRef> {
let old = formulas_down(sheet, start);
let rows: Vec<String> = old.iter().map(|(_, f)| f.clone()).collect();
if rows.last().is_some_and(|f| is_options_row(f)) && parse_model_formulas(&rows).is_ok() {
old.into_iter().map(|(c, _)| c).collect()
} else {
Vec::new()
}
}
/// How many cells the options row of Save Model (`={…}`, one row) spans.
fn options_width(formula: &str) -> u32 {
u32::try_from(formula.split(',').count()).unwrap_or(u32::MAX)
}
/// The options row is an array constant that spills `width - 1` cells to the right of `anchor`.
/// Refused, before anything is written, when the spill would run off the sheet or be blocked
/// (the anchor would show `#SPILL!`): a cell there holds content or another formula's spilled
/// values, or a merged area or a table meets the row. `ours` are the cells Save Model
/// writes or clears (a model saved at the same place before, whose options spill may be
/// replaced); `used` are the model's own cells, which the spill must not cover.
fn check_options_spill(sheet: &Sheet, anchor: CellRef, width: u32, ours: &[CellRef], used: &[CellRef]) -> Result<()> {
if width <= 1 {
return Ok(());
}
let end = anchor.col.checked_add(width - 1).filter(|c| *c < gridcraft_core::MAX_COLS).ok_or_else(|| {
EngineError::Other(format!(
"The saved model's options need {width} cells to the right from {}; choose another place to save it.",
anchor.a1()
))
})?;
let full = RangeRef::new(anchor, CellRef::new(anchor.row, end));
let spill = RangeRef::new(CellRef::new(anchor.row, anchor.col.saturating_add(1)), CellRef::new(anchor.row, end));
let blocked =
|why: String| EngineError::Other(format!("The saved model's options fill {}, but {why}; choose another place to save it.", spill.a1()));
if let Some(c) = used.iter().find(|c| spill.contains(**c)) {
return Err(blocked(format!("{} is a cell of the model", c.a1())));
}
for c in spill.iter() {
if sheet.cells.has(c) {
return Err(blocked(format!("{} isn't empty", c.a1())));
}
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
return Err(blocked(format!("{} holds the values of another formula", c.a1())));
}
}
if sheet.merges.iter().any(|m| m.intersects(&full)) {
return Err(blocked("merged cells are in the way".into()));
}
// A dynamic array can't spill into a table.
if sheet.tables.iter().any(|t| t.range.intersects(&full)) {
return Err(blocked("a table is in the way".into()));
}
Ok(())
}
/// Refused when a cell of the model's column holds another formula's spilled values: the
/// formula written there would block that spill (the other formula would show `#SPILL!`).
/// `ours` are as in [`check_options_spill`]: a model saved at the same place before may be
/// replaced.
fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Result<()> {
for c in column.iter() {
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
return Err(EngineError::Other(format!(
"The saved model would cover {}, which holds the values of another formula; choose another place to save it.",
c.a1()
)));
}
}
Ok(())
}
/// Refused, unless `overwrite`, when a cell of the model's column holds a value, a formula or
/// Python code (a model saved there before included): Save Model then asks before it replaces
/// data, as Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
/// question.
fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> {
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(Cell::has_content));
match taken {
Some(c) if !overwrite => Err(EngineError::Other(format!(
"The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.",
column.a1(),
c.a1()
))),
_ => Ok(()),
}
}
/// `data.solverSaveModel`: writes the model on cells of its sheet, in one step. Saving over a
/// longer model saved at the same cell clears the rest of it, so Load Model reads the new one.
pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let params = match p.get("model") {
Some(m @ Json::Object(_)) => m.clone(),
Some(Json::Null) | None => {
saved_model(&d.wb, d.wb.active_sheet()).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?
}
Some(_) => return Err(bad(SAVE_CMD, "`model` must be data.solver params")),
};
let model = parse_model(s, &params)?;
let to = cell_param_on(s, p, "to", model.sheet)?.ok_or_else(|| bad(SAVE_CMD, "missing `to` (the first cell of the saved model)"))?;
let rows = model_formulas(&model)?;
let count = u32::try_from(rows.len()).unwrap_or(u32::MAX);
let last = to
.row
.checked_add(count.saturating_sub(1))
.filter(|r| *r < gridcraft_core::MAX_ROWS)
.ok_or_else(|| EngineError::Other("The saved model doesn't fit below that cell.".into()))?;
let range = RangeRef::new(to, CellRef::new(last, to.col));
// The model's own cells are never overwritten.
let mut used = vec![model.objective];
used.extend(&model.vars);
for c in &model.cons {
used.extend(&c.lhs);
if let Rhs::Cells(r) = &c.rhs {
used.extend(r);
}
}
if used.iter().any(|c| range.contains(*c)) {
return Err(EngineError::Other(format!("{} overlaps the model's cells; choose another place to save it.", range.a1())));
}
let sheet = model.sheet;
let sh = s.doc()?.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// What is left of a model saved here before; the new model's own cells stay.
let stale: Vec<CellRef> = saved_model_cells(sh, to).into_iter().filter(|c| !range.contains(*c) && !used.contains(c)).collect();
let ours: Vec<CellRef> = range.iter().chain(stale.iter().copied()).collect();
let width = rows.last().map_or(1, |f| options_width(f));
check_column_spills(sh, range, &ours)?;
check_options_spill(sh, CellRef::new(last, to.col), width, &ours, &used)?;
// Last, so the question is asked only when replacing the data would succeed.
check_column_empty(sh, range, bool_param(p, "overwrite").unwrap_or(false))?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let cells: Vec<CellRef> = range.iter().collect();
for (c, f) in cells.iter().zip(&rows) {
let mut cell = Cell::formula(gridcraft_model::Formula::new(f));
cell.style = sh.style_id(*c);
sh.set_cell(*c, cell);
}
for c in &stale {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
}
for c in cells.into_iter().chain(stale) {
cx.touch(sheet, c);
}
Ok(())
})?;
Ok(json!({"range": range.a1()}))
}
/// The formulas of a model range, in order. Every cell up to the last formula must hold one: a
/// cell without a formula there (a constraint overwritten by a constant, say) would shift the
/// rows below it and drop a constraint or the options. Empty cells after the last formula are
/// allowed.
fn cells_with_formulas(r: RangeRef, formula: impl Fn(CellRef) -> Option<String>) -> Result<Vec<String>> {
let cells: Vec<(CellRef, Option<String>)> = r.iter().map(|c| (c, formula(c))).collect();
let end = cells.iter().rposition(|(_, f)| f.is_some()).map_or(0, |i| i + 1);
let mut rows = Vec::with_capacity(end);
for (c, f) in cells.into_iter().take(end) {
let f = f.ok_or_else(|| EngineError::Other(format!("{} holds no formula, so the range isn't a model saved with Save Model.", c.a1())))?;
rows.push(f);
}
Ok(rows)
}
/// A model read down from `start` must end with its options row, which Save Model always
/// writes last: a cell without a formula before it (a constraint cleared, say) would otherwise
/// end the read early and load the rows above it as the whole model, with default options.
fn complete_from_cell(start: CellRef, down: &[(CellRef, String)]) -> Result<()> {
match down.last() {
Some((_, f)) if is_options_row(f) => Ok(()),
_ if down.len() >= MAX_MODEL_ROWS => Err(EngineError::Other(format!(
"No options row ends the model within {MAX_MODEL_ROWS} cells of {}, so it isn't a model saved with Save Model.",
start.a1()
))),
last => {
let gap = last.map_or(Some(start), |(c, _)| c.row.checked_add(1).map(|row| CellRef::new(row, c.col)));
Err(EngineError::Other(match gap {
Some(gap) => format!(
"{} holds no formula before the model's options row, so {} doesn't start a complete model saved with Save Model.",
gap.a1(),
start.a1()
),
None => format!("{} doesn't start a complete model saved with Save Model.", start.a1()),
}))
}
}
}
/// `data.solverLoadModel`: makes a model written by Save Model the active sheet's model.
pub(super) fn load_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet();
let text = str_param(p, "from").ok_or_else(|| bad(LOAD_CMD, "missing `from` (the saved model's range)"))?;
let (sh, body) = split_sheet(text);
if sh.is_some_and(|n| d.wb.sheet_index(&n) != Some(active)) {
return Err(EngineError::Other("Load Model reads a model saved on the active sheet.".into()));
}
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(LOAD_CMD, format!("not a range: `{text}`")))?;
let cap = MAX_MODEL_ROWS;
let sheet = d.wb.sheet(active).ok_or(EngineError::NoDocument)?;
let formula = |c: CellRef| sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone());
let rows: Vec<String> = if r.is_single() {
let down = formulas_down(sheet, r.start);
complete_from_cell(r.start, &down)?;
down.into_iter().map(|(_, f)| f).collect()
} else {
if r.count() > cap as u64 {
return Err(EngineError::Other(format!("The range is too large for a saved model (at most {cap} cells).")));
}
let rows = cells_with_formulas(r, formula)?;
if !rows.last().is_some_and(|f| is_options_row(f)) {
return Err(EngineError::Other(format!(
"{} doesn't end with the model's options row, so it isn't a complete model saved with Save Model.",
r.a1()
)));
}
rows
};
let params = parse_model_formulas(&rows)?;
let model = parse_model(s, &params)?;
if model.sheet != active {
return Err(EngineError::Other("Load Model reads a model of the active sheet.".into()));
}
edit(s, |cx| {
save_model(&mut cx.wb, &model);
Ok(())
})?;
Ok(json!({"model": saved_model(&s.doc()?.wb, active)}))
}
/// Whether a defined name belongs to the saved model of `sheet`.
fn is_sheet_model_name(n: &DefinedName, sheet: usize) -> bool {
n.scope == Some(sheet) && is_model_name(&n.name)
}
/// `data.solverReset`: Reset All removes the active sheet's model. A model the sheet reads from
/// workbook-level names (which other sheets may read too) is left in place and masked by an
/// empty sheet-level `solver_opt`, so no other sheet's model changes.
pub(super) fn reset_model(s: &mut Session, _p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet();
let removed = d.wb.names.iter().filter(|n| is_sheet_model_name(n, active)).count();
let mut after = d.wb.names.clone();
after.retain(|n| !is_sheet_model_name(n, active));
let masked = {
let mut probe = (*d.wb).clone();
probe.names = after;
saved_model(&probe, active).is_some()
};
if removed > 0 || masked {
edit(s, |cx| {
cx.wb.names.retain(|n| !is_sheet_model_name(n, active));
if masked {
cx.wb.names.push(DefinedName {
name: "solver_opt".into(),
scope: Some(active),
formula: "\"\"".into(),
comment: String::new(),
hidden: true,
});
}
Ok(())
})?;
}
Ok(json!({"removed": removed, "masked": masked}))
}
+90 -26
View File
@@ -1,9 +1,10 @@
use super::lp::Outcome;
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
use super::problem::{Evaluator, Problem, as_num, build_problem};
use super::sensitivity::{sensitivity_refusal, sensitivity_report};
use super::{
Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad,
edit, json, optimise, str_param,
Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, bool_param, edit, json,
optimise, str_param,
};
// ---------------------------------------------------------------- reports
@@ -11,6 +12,7 @@ use super::{
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum ReportKind {
Answer,
Sensitivity,
Limits,
}
@@ -18,6 +20,7 @@ impl ReportKind {
fn title(self) -> &'static str {
match self {
ReportKind::Answer => "Answer",
ReportKind::Sensitivity => "Sensitivity",
ReportKind::Limits => "Limits",
}
}
@@ -26,13 +29,14 @@ impl ReportKind {
/// The `reports` list (`None` when absent).
pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<ReportKind>>> {
let Some(list) = p.get("reports") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?;
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer, sensitivity and limits"))?;
let mut kinds = Vec::new();
for k in list {
let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() {
Some("answer") => ReportKind::Answer,
Some("sensitivity") => ReportKind::Sensitivity,
Some("limits") => ReportKind::Limits,
_ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))),
_ => return Err(bad(cmd, format!("unknown report {k} (answer, sensitivity or limits)"))),
};
if !kinds.contains(&kind) {
kinds.push(kind);
@@ -42,14 +46,14 @@ pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<Rep
}
/// A report's cells: (row, column, value, bold).
type Lines = Vec<(u32, u32, Value, bool)>;
pub(super) type Lines = Vec<(u32, u32, Value, bool)>;
fn abs_ref(c: CellRef) -> String {
pub(super) fn abs_ref(c: CellRef) -> String {
format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1)
}
/// A cell's name in the reports: the nearest text to its left and the nearest text above it.
fn cell_name(sh: &Sheet, c: CellRef) -> String {
pub(super) fn cell_name(sh: &Sheet, c: CellRef) -> String {
let text = |c: CellRef| match sh.value(c) {
Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()),
_ => None,
@@ -68,7 +72,7 @@ fn method_name(m: Method) -> &'static str {
}
/// Header labels in bold from column 1, then the rows below them; returns the next free row.
fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
pub(super) fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
for (k, h) in headers.iter().enumerate() {
out.push((row, 1 + k as u32, Value::text(*h), true));
}
@@ -92,7 +96,7 @@ pub(super) struct ReportInput<'a> {
pub(super) budget: u64,
}
fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
pub(super) fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true));
out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false));
3
@@ -255,18 +259,67 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
}
/// Builds every report before the solution or saved model can change.
pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
/// What happens to a Sensitivity report the model allows but the solution doesn't: `Refuse`
/// fails the call before anything changes; `Skip` leaves the report out, with its reason, and
/// writes the others. `optimum` is false when the solve stopped before an optimum, so the
/// report isn't attempted.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum NotOptimal {
Refuse,
Skip { optimum: bool },
}
/// The reports left out under [`NotOptimal::Skip`], as `{report, reason}`.
pub(super) type Skipped = Vec<Json>;
pub(super) fn prepare_reports(
d: &DocState,
kinds: &[ReportKind],
inp: &ReportInput,
not_optimal: NotOptimal,
) -> Result<(Vec<(String, Lines)>, Skipped)> {
if kinds.is_empty() {
return Ok(Vec::new());
return Ok((Vec::new(), Vec::new()));
}
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
// A request that must write a sheet is refused before any report is built (a Limits report
// runs two optimisations per variable); only a Sensitivity report that may be skipped waits
// for the check after the loop.
let must_write = kinds.iter().any(|k| *k != ReportKind::Sensitivity || not_optimal == NotOptimal::Refuse);
if must_write && d.wb.protected_structure {
return Err(protected());
}
// A model that has no Sensitivity report (integer, Evolutionary, Value Of) is refused in
// both modes before any report is built: a Limits report listed first would otherwise run
// its two optimisations per variable for an error. Only the solution's own refusal (not
// optimal) waits for the loop, where `Skip` may leave the report out.
if kinds.contains(&ReportKind::Sensitivity)
&& let Some(why) = sensitivity_refusal(inp.model, inp.prob)
{
return Err(EngineError::Other(why.into()));
}
let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?;
let mut built = Vec::new();
let mut skipped = Vec::new();
for k in kinds {
let lines = match k {
ReportKind::Answer => answer_report(d, sh, inp),
ReportKind::Sensitivity => match not_optimal {
NotOptimal::Skip { optimum } => {
let got = if optimum {
sensitivity_report(d, sh, inp).map_err(|e| e.to_string())
} else {
Err("Solver did not reach an optimal solution, so the Sensitivity report was not written.".to_string())
};
match got {
Ok(lines) => lines,
Err(reason) => {
skipped.push(json!({"report": "sensitivity", "reason": reason}));
continue;
}
}
}
NotOptimal::Refuse => sensitivity_report(d, sh, inp)?,
},
ReportKind::Limits => limits_report(d, sh, inp)?,
};
let name = (1..=10_000u32)
@@ -275,7 +328,16 @@ pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportIn
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
built.push((name, lines));
}
Ok(built)
// Only a report sheet changes the workbook's structure, so a request whose reports were all
// skipped still succeeds in a protected workbook.
if !built.is_empty() && d.wb.protected_structure {
return Err(protected());
}
Ok((built, skipped))
}
fn protected() -> EngineError {
EngineError::Other("The workbook is protected and cannot be changed.".into())
}
/// Inserts prepared reports before the model's sheet, which stays active.
@@ -290,9 +352,9 @@ pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>
let mut sheet = Sheet::new(name);
for (r, c, v, b) in lines {
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style, py: None });
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 155.0), (6, 140.0), (7, 140.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
@@ -321,9 +383,9 @@ pub(super) fn insert_report_selections(s: &mut Session, at: usize, count: usize)
/// `data.solverReport`: reports for a solution of the sheet's saved model.
pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?;
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, sensitivity, limits)"))?;
if kinds.is_empty() {
return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)"));
return Err(bad(REPORT_CMD, "`reports` is empty (answer, sensitivity, limits)"));
}
let d = s.doc()?;
// The given model as is, else the saved model with any params given here over it.
@@ -362,15 +424,17 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
};
let solution = point("values")?;
let original = if p.get("original").is_some() { point("original")? } else { solution.clone() };
let budget = params
.get("maxEvaluations")
.and_then(Json::as_f64)
.filter(|v| v.is_finite() && *v >= 1.0)
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
let budget = model.max_evals;
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
let not_optimal = if bool_param(p, "skipNotOptimal").unwrap_or(false) { NotOptimal::Skip { optimum: true } } else { NotOptimal::Refuse };
let (built, skipped) = prepare_reports(s.doc()?, &kinds, &inp, not_optimal)?;
// Nothing left to write (every report skipped): no edit, so no Undo step.
let sheets = if built.is_empty() { Vec::new() } else { edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))? };
insert_report_selections(s, model.sheet, sheets.len());
Ok(json!({"sheets": sheets}))
let mut out = json!({"sheets": sheets});
if !skipped.is_empty() {
out["skippedReports"] = json!(skipped);
}
Ok(out)
}
+614
View File
@@ -0,0 +1,614 @@
//! The Sensitivity report. Simplex LP: each variable's reduced cost and the range of its
//! objective coefficient over which the basis stays optimal, and each constraint's shadow price
//! and the range of its right side over which the shadow price holds, from the final tableau of
//! the LP linearised at the solution. GRG Nonlinear: the reduced gradients and the Lagrange
//! multipliers of the binding constraints at the solution, from difference gradients.
use super::lp::{FinalTableau, LpRow, Outcome, Row, Sense, SimplexProgram, simplex_program, solve_lp_tableau};
use super::model::{Goal, Method, Model};
use super::problem::{Evaluator, Point, Problem, as_num};
use super::reports::{Lines, ReportInput, ReportKind, abs_ref, cell_name, table, title};
use super::{CellRef, DocState, EPS, EngineError, Result, Sheet, Trial, Value};
/// Excel prints an unlimited allowable change as 1E+30.
const NO_LIMIT: f64 = 1e30;
/// Most arithmetic the Lagrange multipliers of a nonlinear model may take (about a second): a
/// pass over k binding rows and f free variables costs about 3·k²·f (the normal equations and
/// each multiplier's noise), and each pass drops at most one row, so a model at the constraint
/// limit could otherwise take minutes.
#[cfg(not(test))]
const MULTIPLIER_WORK: u64 = 1_000_000_000;
/// A low limit in tests, so the refusal is reachable with a small model.
#[cfg(test)]
const MULTIPLIER_WORK: u64 = 20_000;
/// Why a model has no Sensitivity report (`None` when it has one).
pub(super) fn sensitivity_refusal(m: &Model, prob: &Problem) -> Option<&'static str> {
if prob.integer.iter().any(|b| *b) {
Some("The Sensitivity report doesn't apply to models with integer or binary constraints.")
} else if m.method == Method::Evolutionary {
Some("The Sensitivity report is available for Simplex LP and GRG Nonlinear.")
} else if matches!(m.goal, Goal::Value(_)) {
Some("The Sensitivity report needs an objective to maximise or minimise.")
} else {
None
}
}
/// A sensitivity figure: values within `noise` of zero (rounding) print as 0, unlimited ones
/// as 1E+30. The noise level is local to the figure: it comes from the terms the figure is a
/// sum of (the rounding error of a sum scales with the sum of its terms' sizes), never from
/// the model's largest coefficient or gradient component, so a small price or gradient keeps
/// its value beside a large unrelated one, and never from the objective's value, so adding a
/// constant to the objective changes nothing the report shows.
fn sens_num(v: f64, noise: f64) -> Value {
if !v.is_finite() || v >= NO_LIMIT {
Value::number(NO_LIMIT)
} else if v.abs() <= noise {
Value::number(0.0)
} else {
Value::number(v)
}
}
fn num(v: f64) -> Value {
if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }
}
/// The smallest ratio `num / den` over the `den` above zero (the step to the first bound).
fn min_ratio(pairs: impl Iterator<Item = (f64, f64)>) -> f64 {
pairs.filter(|(_, den)| *den > EPS).map(|(num, den)| num.max(0.0) / den).fold(f64::INFINITY, f64::min)
}
/// Solves the square system `a·x = b` by Gaussian elimination with partial pivoting; unknowns
/// of a singular system that have no pivot are 0.
fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
let n = b.len();
let scale = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(f64::MIN_POSITIVE);
let mut pivots: Vec<Option<usize>> = vec![None; n];
let mut row = 0;
for col in 0..n {
let Some(p) = (row..n).max_by(|i, k| {
let v = |r: &usize| a.get(*r).and_then(|x| x.get(col)).map_or(0.0, |v| v.abs());
v(i).total_cmp(&v(k))
}) else {
break;
};
if a.get(p).and_then(|x| x.get(col)).is_none_or(|v| v.abs() <= 1e-12 * scale) {
continue;
}
a.swap(row, p);
b.swap(row, p);
let (Some(prow), Some(pb)) = (a.get(row).cloned(), b.get(row).copied()) else { break };
let pv = prow.get(col).copied().unwrap_or(1.0);
for (i, (r, bi)) in a.iter_mut().zip(b.iter_mut()).enumerate() {
if i == row {
continue;
}
let f = r.get(col).copied().unwrap_or(0.0) / pv;
if f != 0.0 {
for (v, p) in r.iter_mut().zip(&prow) {
*v -= f * p;
}
*bi -= f * pb;
}
}
if let Some(e) = pivots.get_mut(col) {
*e = Some(row);
}
row += 1;
}
pivots
.iter()
.enumerate()
.map(|(col, p)| match p {
Some(r) => b.get(*r).copied().unwrap_or(0.0) / a.get(*r).and_then(|x| x.get(col)).copied().unwrap_or(1.0),
None => 0.0,
})
.collect()
}
fn out_of_evaluations() -> EngineError {
EngineError::Other("Solver ran out of evaluations.".into())
}
fn not_optimal() -> EngineError {
EngineError::Other("The Sensitivity report needs an optimal solution: these values are not optimal for the model. Solve it first.".into())
}
/// What both report bodies share: the model, the solution, its objective value and the rows'
/// final values and right sides.
struct At<'a> {
inp: &'a ReportInput<'a>,
sh: &'a Sheet,
point: Point,
finals: Vec<(f64, f64)>,
}
impl At<'_> {
fn var_head(&self, j: usize, c: CellRef) -> Vec<Value> {
vec![Value::text(abs_ref(c)), Value::text(cell_name(self.sh, c)), num(self.inp.solution.get(j).copied().unwrap_or(f64::NAN))]
}
fn row_head(&self, i: usize, r: &Row) -> Vec<Value> {
let fin = self.finals.get(i).map_or(f64::NAN, |f| f.0);
vec![Value::text(abs_ref(r.lhs)), Value::text(cell_name(self.sh, r.lhs)), num(fin)]
}
}
/// The variable rows, the constraint rows and their headers.
type Body = (Vec<Vec<Value>>, Vec<Vec<Value>>, Vec<&'static str>, Vec<&'static str>);
pub(super) fn sensitivity_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
let m = inp.model;
if let Some(why) = sensitivity_refusal(m, inp.prob) {
return Err(EngineError::Other(why.into()));
}
let n = m.vars.len();
let x = &inp.solution;
let mut ev = Evaluator {
trial: Trial::new(d),
sheet: m.sheet,
vars: m.vars.clone(),
objective: m.objective,
rows: inp.prob.rows.clone(),
evals: 0,
// Room for the gradients (GRG) or two linearisations (Simplex LP) at the least.
budget: inp.budget.max(3 * n as u64 + 4),
target: None,
};
let point = ev.eval(x).ok_or_else(out_of_evaluations)?;
// The rows' final values and right sides at the solution.
let mut cells = Vec::new();
for r in &inp.prob.rows {
cells.push(r.lhs);
cells.extend(r.rhs);
}
let mut trial = Trial::new(d);
let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect();
let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect();
let mut vals = trial.run(&sets, &reads).into_iter().map(|v| as_num(&v));
let finals: Vec<(f64, f64)> = inp
.prob
.rows
.iter()
.map(|r| {
let l = vals.next().unwrap_or(f64::NAN);
(l, if r.rhs.is_some() { vals.next().unwrap_or(f64::NAN) } else { r.rhs_const })
})
.collect();
let at = At { inp, sh, point, finals };
let (var_rows, con_rows, var_headers, con_headers) =
if m.method == Method::Simplex { simplex_body(&mut ev, &at)? } else { nonlinear_body(&mut ev, &at)? };
let mut out = Lines::new();
let r = title(&mut out, ReportKind::Sensitivity, &sh.name);
out.push((r, 0, Value::text("Variable Cells"), true));
let r = table(&mut out, r + 1, &var_headers, var_rows);
out.push((r, 0, Value::text("Constraints"), true));
table(&mut out, r + 1, &con_headers, con_rows);
Ok(out)
}
/// Simplex LP: reduced costs, shadow prices and their ranges from the final tableau.
fn simplex_body(ev: &mut Evaluator, at: &At) -> Result<Body> {
let (inp, m) = (at.inp, at.inp.model);
// The program the solve built, from the same start values (data.solver's x0, which Solver
// Results passes back as `original`), so the solve below repeats its pivots. Start values
// the model can't be evaluated at (given only to the report) fall back to the solution.
let start: Vec<f64> = inp.original.iter().map(|v| if v.is_finite() { *v } else { 0.0 }).collect();
let built = match simplex_program(ev, m.goal, &start) {
Err("nonlinear" | "error") if start != inp.solution => simplex_program(ev, m.goal, &inp.solution),
other => other,
};
let SimplexProgram { cf, c, rows, mut work } = built.map_err(|e| {
if e == "budget" {
out_of_evaluations()
} else {
EngineError::Other("The model isn't linear in the variable cells, so Simplex LP can't report its sensitivity.".into())
}
})?;
let (outcome, tab) = solve_lp_tableau(&c, &rows, &inp.prob.lo, &inp.prob.hi, &mut work);
let (Outcome::Solved(vertex, best, _), Some(tab)) = (outcome, tab) else {
return Err(EngineError::Other("Solver could not find an optimal basis for the Sensitivity report.".into()));
};
// The report describes the optimum, so the values it shows must be one.
if !optimal_point(&c, &rows, (&inp.prob.lo, &inp.prob.hi), &inp.solution, best) {
return Err(not_optimal());
}
// The prices and ranges come from the tableau of `vertex`, so the values shown must be that
// vertex. Another optimum with the same objective (when the optimum is not unique) has other
// ranges, so it is refused too, although it is optimal.
let near = |a: f64, b: f64| (a - b).abs() <= super::PRECISION * (1.0 + a.abs().max(b.abs()));
if vertex.len() != inp.solution.len() || !vertex.iter().zip(&inp.solution).all(|(a, b)| near(*a, *b)) {
return Err(EngineError::Other(
"The Sensitivity report needs the values at the Simplex LP solution: these values are another optimal solution, whose ranges differ. Solve the model with Simplex LP first."
.into(),
));
}
// Minimising `sign · objective`.
let sign = if m.goal == Goal::Max { -1.0 } else { 1.0 };
// A shadow price is the dual yᵢ = Σᵣ c_B(r)·B⁻¹(r, i): the row's unit column of the final
// tableau holds B⁻¹'s column i, and c_B(r) is the cost of the column basic in row r (a
// slack or artificial costs nothing). The rounding error of a sum scales with the sum of
// its terms' sizes, so a price below 1E-9 of Σᵣ |c_B(r)·B⁻¹(r, i)| is noise. The bound is
// the row's own: a small price keeps its value beside a large coefficient that plays no
// part in it, and a constant in the objective (no coefficient) changes no bound. Allowable
// changes to a coefficient or a right side are ratios the tableau's own pivot tolerance
// (`EPS`) already bounds.
let cost_size = |b: usize| tab.cols.get(b).map_or(0.0, |mp| cf.get(mp.var).copied().unwrap_or(0.0).abs());
let price_terms = |col: usize| (0..tab.t.len()).map(|r| tab.basis.get(r).map_or(0.0, |b| cost_size(*b)) * entry(&tab, r, col).abs()).sum::<f64>();
let (shadow, terms): (Vec<f64>, Vec<f64>) = (0..rows.len())
.map(|i| {
let col = tab.ident.get(i).copied().unwrap_or(usize::MAX);
(sign * flip(&tab, i) * -tab.obj.get(col).copied().unwrap_or(0.0), price_terms(col))
})
.unzip();
let noise = |terms: f64| 1e-9 * terms.max(f64::MIN_POSITIVE);
let con_rows = ev
.rows
.iter()
.enumerate()
.map(|(i, r)| {
// The basic values move along the row's unit column as its right side changes.
let col = tab.ident.get(i).copied().unwrap_or(usize::MAX);
let dir: Vec<(f64, f64)> =
(0..tab.t.len()).filter(|r| real_basic(&tab, *r)).map(|r| (entry(&tab, r, tab.width), flip(&tab, i) * entry(&tab, r, col))).collect();
// An artificial still basic after phase one stands for a row the others make
// redundant, and it is zero there. When this right side moves it, any change alone
// leaves no solution, so both ranges are 0.
let pinned = (0..tab.t.len()).any(|r| !real_basic(&tab, r) && entry(&tab, r, col).abs() > EPS);
let (increase, decrease) =
if pinned { (0.0, 0.0) } else { (min_ratio(dir.iter().map(|(v, d)| (*v, -d))), min_ratio(dir.iter().map(|(v, d)| (*v, *d)))) };
let b = at.finals.get(i).map_or(f64::NAN, |f| f.1);
let mut row = at.row_head(i, r);
let price = sens_num(shadow.get(i).copied().unwrap_or(0.0), noise(terms.get(i).copied().unwrap_or(0.0)));
row.extend([price, num(b), sens_num(increase, EPS), sens_num(decrease, EPS)]);
row
})
.collect();
let nonbasic: Vec<usize> = (0..tab.first_art).filter(|l| !tab.basis.contains(l)).collect();
let var_rows = m
.vars
.iter()
.enumerate()
.map(|(j, c)| {
let coef = cf.get(j).copied().unwrap_or(0.0);
let used: f64 = rows.iter().zip(&shadow).map(|((a, _, _), p)| a.get(j).copied().unwrap_or(0.0) * p).sum();
// The reduced cost cf_j − Σᵢ a_ij·yᵢ is a sum of cf_j and the prices' terms times
// a_ij: the prices' terms rather than the prices, since a price that is a small
// difference of large terms carries their rounding error into every reduced cost
// it is used in.
let used_terms: f64 = rows.iter().zip(&terms).map(|((a, _, _), t)| a.get(j).copied().unwrap_or(0.0).abs() * t).sum();
// A change Δ in the coefficient changes each nonbasic reduced cost by Δ·e.
let own = |col: usize| tab.cols.get(col).filter(|mp| mp.var == j).map_or(0.0, |mp| mp.sign);
let moves: Vec<(f64, f64)> = nonbasic
.iter()
.map(|&l| {
let through_basis: f64 = (0..tab.t.len()).map(|r| tab.basis.get(r).map_or(0.0, |b| own(*b)) * entry(&tab, r, l)).sum();
(tab.obj.get(l).copied().unwrap_or(0.0), sign * (own(l) - through_basis))
})
.collect();
let increase = min_ratio(moves.iter().map(|(d, e)| (*d, -e)));
let decrease = min_ratio(moves.iter().map(|(d, e)| (*d, *e)));
let mut row = at.var_head(j, *c);
row.extend([sens_num(coef - used, noise(coef.abs() + used_terms)), num(coef), sens_num(increase, EPS), sens_num(decrease, EPS)]);
row
})
.collect();
Ok((
var_rows,
con_rows,
vec!["Cell", "Name", "Final Value", "Reduced Cost", "Objective Coefficient", "Allowable Increase", "Allowable Decrease"],
vec!["Cell", "Name", "Final Value", "Shadow Price", "Constraint R.H. Side", "Allowable Increase", "Allowable Decrease"],
))
}
/// Whether `x` satisfies the LP (rows and bounds) and reaches its optimum `best` (minimising
/// `c·x`), within the constraint precision.
fn optimal_point(c: &[f64], rows: &[LpRow], (lo, hi): (&[f64], &[f64]), x: &[f64], best: f64) -> bool {
let near = |v: f64, size: f64| v <= super::PRECISION * (1.0 + size);
let dot = |a: &[f64]| a.iter().zip(x).fold((0.0, 0.0), |(s, m), (p, q)| (s + p * q, m + (p * q).abs()));
let rows_hold = rows.iter().all(|(a, sense, b)| {
let (lhs, size) = dot(a);
let size = size + b.abs();
match sense {
Sense::Le => near(lhs - b, size),
Sense::Ge => near(b - lhs, size),
Sense::Eq => near((lhs - b).abs(), size),
}
});
let bounds_hold = x.iter().enumerate().all(|(j, v)| {
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
v.is_finite() && near(l - v, v.abs()) && near(v - h, v.abs())
});
let (value, size) = dot(c);
// Two-sided: a point just outside a bound can beat the optimum by far more than the
// precision when its costs are large.
rows_hold && bounds_hold && near((value - best).abs(), size + best.abs())
}
fn entry(tab: &FinalTableau, r: usize, col: usize) -> f64 {
tab.t.get(r).and_then(|row| row.get(col)).copied().unwrap_or(0.0)
}
fn real_basic(tab: &FinalTableau, r: usize) -> bool {
tab.basis.get(r).is_some_and(|b| *b < tab.first_art)
}
/// -1 for a row the tableau negated, else 1.
fn flip(tab: &FinalTableau, i: usize) -> f64 {
if tab.flipped.get(i).copied().unwrap_or(false) { -1.0 } else { 1.0 }
}
/// Central-difference gradients, inside the bounds, of the objective and of each row's
/// `lhs − rhs` at the solution.
fn gradients(ev: &mut Evaluator, inp: &ReportInput) -> Result<(Vec<f64>, Vec<Vec<f64>>)> {
let x = &inp.solution;
let n = x.len();
let mut df = vec![0.0; n];
let mut dg = vec![vec![0.0; n]; ev.rows.len()];
for j in 0..n {
let xj = x.get(j).copied().unwrap_or(0.0);
let (l, u) = (inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY));
let h = 1e-6 * xj.abs().max(1.0);
let (a, b) = ((xj - h).max(l), (xj + h).min(u));
if b <= a {
continue;
}
let mut point = |v: f64| {
let mut p = x.clone();
if let Some(e) = p.get_mut(j) {
*e = v;
}
ev.eval(&p).ok_or_else(out_of_evaluations)
};
let (pa, pb) = (point(a)?, point(b)?);
if let Some(e) = df.get_mut(j) {
*e = (pb.f - pa.f) / (b - a);
}
for (row, (ga, gb)) in dg.iter_mut().zip(pa.g.iter().zip(&pb.g)) {
if let Some(e) = row.get_mut(j) {
*e = (gb - ga) / (b - a);
}
}
}
Ok((df, dg))
}
/// The rounding noise in the Lagrange multiplier of row `i`, one of the `active` rows whose
/// multipliers `lambda` solve the normal equations of ∇f = Σ λₖ ∇gₖ over the `free` variables.
/// Row i's equation gives λᵢ = (Σⱼ gᵢⱼ·dfⱼ − Σ_{k≠i} λₖ·Σⱼ gᵢⱼ·gₖⱼ) / Σⱼ gᵢⱼ²; the difference
/// gradients are good to about 1E-6 of each component, and the rounding error of a sum scales
/// with the sum of its terms' sizes, so the noise is 1E-6 of the terms' sizes over the row's
/// own squared norm. The bound is local to the row: another row enters only through its
/// coupling term with this one, so a row whose gradient is orthogonal to the others has a bound
/// from its own gradient alone, and a large component of ∇f that the row's gradient doesn't
/// touch hides nothing. Nearly parallel active gradients make the normal equations
/// ill-conditioned and amplify every multiplier's error beyond any bound local to a row; such
/// multipliers are ill-determined whatever the bound, and they print as computed.
fn multiplier_noise(i: usize, active: &[usize], lambda: &[f64], free: &[usize], (df, dg): (&[f64], &[Vec<f64>])) -> f64 {
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let own: f64 = free.iter().map(|&j| grad(i, j) * grad(i, j)).sum();
let objective: f64 = free.iter().map(|&j| (grad(i, j) * df.get(j).copied().unwrap_or(0.0)).abs()).sum();
let coupling: f64 = active
.iter()
.zip(lambda)
.filter(|(k, _)| **k != i)
.map(|(&k, l)| free.iter().map(|&j| (grad(i, j) * grad(k, j) * l).abs()).sum::<f64>())
.sum();
1e-6 * (objective + coupling) / own.max(f64::MIN_POSITIVE)
}
/// Least squares on the free variables: ∇f = Σ λᵢ ∇gᵢ over the `active` rows. An inequality's
/// multiplier has the sign that tightening it costs (λ ≥ 0 for `lhs ≤ rhs` when maximising); a
/// row whose multiplier has the other sign beyond its own noise ([`multiplier_noise`], the
/// criterion the report rounds it with) isn't binding, so it leaves and the rest are solved
/// again; the objective's value plays no part. Returns each row's multiplier and its noise (0
/// and 0 for a row that doesn't bind). The work is bounded by `budget` (see
/// [`MULTIPLIER_WORK`]); past it the report is refused.
fn multipliers(
rows: &[Row],
goal: Goal,
mut active: Vec<usize>,
free: &[usize],
(df, dg): (&[f64], &[Vec<f64>]),
budget: u64,
) -> Result<(Vec<f64>, Vec<f64>)> {
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let goal_sign = if goal == Goal::Max { 1.0 } else { -1.0 };
let mut lambda = vec![0.0; rows.len()];
let mut noise = vec![0.0; rows.len()];
let mut work = 0u64;
while !active.is_empty() {
let (k, f) = (active.len() as u64, (free.len() as u64).max(1));
work = work.saturating_add(3u64.saturating_mul(k).saturating_mul(k).saturating_mul(f));
if work > budget {
return Err(EngineError::Other(
"The Sensitivity report of this nonlinear model has too many binding constraints to work out in reasonable time.".into(),
));
}
let normal: Vec<Vec<f64>> =
active.iter().map(|&i| active.iter().map(|&k| free.iter().map(|&j| grad(i, j) * grad(k, j)).sum()).collect()).collect();
let rhs: Vec<f64> = active.iter().map(|&i| free.iter().map(|&j| grad(i, j) * df.get(j).copied().unwrap_or(0.0)).sum()).collect();
let solved = solve_dense(normal, rhs);
let bounds: Vec<f64> = active.iter().map(|&i| multiplier_noise(i, &active, &solved, free, (df, dg))).collect();
let wrong = active
.iter()
.zip(&solved)
.zip(&bounds)
.enumerate()
.filter_map(|(k, ((&i, l), bound))| {
let want = match rows.get(i).map_or(Sense::Eq, |r| r.sense) {
Sense::Le => goal_sign,
Sense::Ge => -goal_sign,
Sense::Eq => return None,
};
(want * l < -bound).then_some((k, want * l))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
match wrong {
Some((k, _)) => {
active.remove(k);
}
None => {
for ((i, l), bound) in active.iter().zip(solved).zip(bounds) {
if let (Some(e), Some(n)) = (lambda.get_mut(*i), noise.get_mut(*i)) {
*e = l;
*n = bound;
}
}
break;
}
}
}
Ok((lambda, noise))
}
/// Whether the solution is a Karush-Kuhn-Tucker point of the model, from the gradients the
/// report computed: every row and bound holds, and each reduced gradient (the objective's
/// gradient less what the binding rows' multipliers explain) is near zero relative to `terms`,
/// the sizes of the terms it is made of, or, for a variable at a bound, points only out of that
/// bound in the objective's direction. A binding row whose multiplier had the wrong sign was
/// dropped by [`multipliers`], so the part of the gradient it would have explained remains in
/// the reduced gradients and the point is refused here. Every tolerance is local to the row or
/// variable it tests: a large right side or gradient component elsewhere in the model excuses
/// nothing here.
fn kkt_point(at: &At, rows: &[Row], terms: &[f64], reduced: &[f64]) -> bool {
let (inp, x) = (at.inp, &at.inp.solution);
// Feasibility: each row within ten times the constraint precision of its own right side.
// The nonlinear method accepts a solution within that of the model's largest constant right
// side, which lets a small row go by its own size; a row over by more than its own
// precision isn't satisfied, whatever the other rows' sizes, so the report refuses it.
let rows_hold = rows.iter().enumerate().all(|(i, r)| {
let (g, b) = (at.point.g.get(i).copied().unwrap_or(f64::NAN), at.finals.get(i).map_or(f64::NAN, |f| f.1));
let over = match r.sense {
Sense::Le => g,
Sense::Ge => -g,
Sense::Eq => g.abs(),
};
over <= 10.0 * super::PRECISION * (1.0 + b.abs())
});
if !rows_hold || !at.point.f.is_finite() {
return false;
}
// Stationarity: each reduced gradient relative to the terms it is made of (the objective's
// component and the multipliers' parts, at least 1). At optima the nonlinear method solves,
// a reduced gradient is below about 1E-6 of those terms; 1E-3 leaves a wide margin for the
// method's stopping test and for rounding in the difference gradients of badly scaled
// models, and still refuses a point whose objective improves visibly along a direction the
// constraints allow. The bound is the variable's own: with a bound from the gradient's
// largest component, max 1E9·x + y at (1, 0.5) passed, although raising y gains 1 per unit
// against a tolerance of 1E6.
// Moving up a variable improves a maximum by +r and a minimum by −r per unit.
let sign = if inp.model.goal == Goal::Max { 1.0 } else { -1.0 };
x.iter().enumerate().all(|(j, &v)| {
let (l, h) = (inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY));
let near = super::PRECISION * (1.0 + v.abs());
if !v.is_finite() || v < l - near || v > h + near {
return false;
}
// The same test for "at a bound" as the choice of free variables above.
let edge = 1e-6 * v.abs().max(1.0);
let (at_lo, at_hi) = (v <= l + edge, v >= h - edge);
let gain = reduced.get(j).map_or(f64::NAN, |r| sign * r);
let tol = 1e-3 * terms.get(j).copied().unwrap_or(0.0).max(1.0);
gain.is_finite() && (at_hi || gain <= tol) && (at_lo || gain >= -tol)
})
}
/// GRG Nonlinear: reduced gradients and the binding rows' Lagrange multipliers.
fn nonlinear_body(ev: &mut Evaluator, at: &At) -> Result<Body> {
let (inp, m) = (at.inp, at.inp.model);
let x = &inp.solution;
let (df, dg) = gradients(ev, inp)?;
// Binding rows (within the nonlinear method's accuracy) and the variables strictly inside
// their bounds.
let active: Vec<usize> = ev
.rows
.iter()
.enumerate()
.filter(|(i, r)| {
let (g, b) = (at.point.g.get(*i).copied().unwrap_or(f64::NAN), at.finals.get(*i).map_or(0.0, |f| f.1));
r.sense == Sense::Eq || g.abs() <= 1e-4 * (1.0 + b.abs())
})
.map(|(i, _)| i)
.collect();
let free: Vec<usize> = (0..x.len())
.filter(|&j| {
let v = x.get(j).copied().unwrap_or(0.0);
let tol = 1e-6 * v.abs().max(1.0);
v > inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY) + tol && v < inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY) - tol
})
.collect();
let (lambda, lambda_noise) = multipliers(&ev.rows, m.goal, active, &free, (&df, &dg), MULTIPLIER_WORK)?;
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let reduced: Vec<f64> =
(0..x.len()).map(|j| df.get(j).copied().unwrap_or(0.0) - lambda.iter().enumerate().map(|(i, l)| l * grad(i, j)).sum::<f64>()).collect();
// A reduced gradient dfⱼ − Σᵢ λᵢ·gᵢⱼ is a sum of the gradient's own component and the
// multipliers' terms; the sizes of those terms bound its rounding error and scale the test
// of whether it is zero.
let terms: Vec<f64> = (0..x.len())
.map(|j| df.get(j).copied().unwrap_or(0.0).abs() + lambda.iter().enumerate().map(|(i, l)| (l * grad(i, j)).abs()).sum::<f64>())
.collect();
// The report describes the optimum, so the values it shows must be one.
if !kkt_point(at, &ev.rows, &terms, &reduced) {
return Err(not_optimal());
}
// Difference gradients are good to about 1E-6 of each component, so a reduced gradient's
// noise is 1E-6 of its terms' sizes, plus each multiplier's own noise
// ([`multiplier_noise`]) times |gᵢⱼ|. The bound is the variable's own: a small gradient
// keeps its value beside a large component of another variable.
let reduced_noise = |j: usize| {
let carried: f64 = lambda_noise.iter().enumerate().map(|(i, n)| if grad(i, j) == 0.0 { 0.0 } else { n * grad(i, j).abs() }).sum();
1e-6 * terms.get(j).copied().unwrap_or(0.0) + carried
};
let var_rows = m
.vars
.iter()
.enumerate()
.map(|(j, c)| {
let mut row = at.var_head(j, *c);
row.push(sens_num(reduced.get(j).copied().unwrap_or(f64::NAN), reduced_noise(j)));
row
})
.collect();
let con_rows = ev
.rows
.iter()
.enumerate()
.map(|(i, r)| {
let mut row = at.row_head(i, r);
row.push(sens_num(lambda.get(i).copied().unwrap_or(0.0), lambda_noise.get(i).copied().unwrap_or(0.0)));
row
})
.collect();
Ok((var_rows, con_rows, vec!["Cell", "Name", "Final Value", "Reduced Gradient"], vec!["Cell", "Name", "Final Value", "Lagrange Multiplier"]))
}
#[cfg(test)]
mod tests {
use super::{Goal, MULTIPLIER_WORK, Row, Sense, multipliers};
use gridcraft_core::CellRef;
#[test]
fn multipliers_at_the_constraint_limit_stop_at_the_work_limit() {
// 1,000 binding `<=` rows over 200 free variables, each multiplier with the wrong sign:
// without a limit every pass would drop one row and solve again, about 4·10⁸ operations
// a pass for up to 1,000 passes. The limit (lowered in tests) refuses it at once.
let (k, f) = (1000usize, 200usize);
let rows: Vec<Row> = (0..k).map(|_| Row { lhs: CellRef::new(0, 0), sense: Sense::Le, rhs: None, rhs_const: 0.0 }).collect();
let dg: Vec<Vec<f64>> = (0..k).map(|i| (0..f).map(|j| if j == i % f { 1.0 } else { 0.0 }).collect()).collect();
let df = vec![-1.0; f];
let free: Vec<usize> = (0..f).collect();
let got = multipliers(&rows, Goal::Max, (0..k).collect(), &free, (&df, &dg), MULTIPLIER_WORK);
assert!(got.is_err());
// A small model within the limit still gets its multipliers: ∇f = (1, 2) on two
// binding rows with unit gradients.
let dg2 = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let (got, noise) = multipliers(&rows[..2], Goal::Max, vec![0, 1], &free[..2], (&[1.0, 2.0], &dg2), MULTIPLIER_WORK).unwrap();
assert_eq!(got, vec![1.0, 2.0]);
// Each multiplier's noise is 1E-6 of its own row's term (1 · 1 and 1 · 2 over a unit
// norm): the rows are orthogonal, so neither enters the other's bound.
assert_eq!(noise, vec![1e-6, 2e-6]);
}
}
File diff suppressed because it is too large Load Diff
+105 -37
View File
@@ -15,7 +15,7 @@
//! 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::collections::{BTreeMap, HashSet};
use std::fmt::Write as _;
use gridcraft_model::{DefinedName, Workbook};
@@ -102,15 +102,29 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
assemble(&parts)
}
/// What a listing found: the first [`MAX_ITEMS`] items or keys, and how many more there were.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Listing<T> {
pub found: T,
/// Items past the cap, not listed. A caller says so rather than letting them vanish.
pub more: usize,
}
/// 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()
/// over the names: the first [`MAX_ITEMS`] items that can be read, and the count of the readable
/// ones past them. An item over the size limit is neither listed nor counted, so it takes no
/// place another item could have. ([`keys`] lists keys without reading the items.)
pub fn items(wb: &Workbook, prefix: &str) -> Listing<BTreeMap<String, String>> {
let mut listing: Listing<BTreeMap<String, String>> = Listing::default();
for (k, parts) in group(wb, prefix) {
let Some(text) = parts.is_item().then(|| assemble(&parts)).flatten() else { continue };
if listing.found.len() < MAX_ITEMS {
listing.found.insert(k, text);
} else {
listing.more = listing.more.saturating_add(1);
}
}
listing
}
/// The text of the item held by `parts`, as [`get`] reads it.
@@ -169,28 +183,44 @@ fn unescape(s: &str) -> String {
/// 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))));
set_many(wb, &[(key.to_string(), text.to_string())])
}
/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything
/// changes and one pass over the names for all of them, so writing a thousand items into a
/// workbook with many thousands of names stays near-linear.
pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> {
let mut planned = Vec::new();
let mut keys: HashSet<String> = HashSet::new();
for (key, text) in items {
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)
)));
}
// Two items under one key (names compare without case) would leave two hidden names of
// one name, of which an xlsx file keeps one: a caller's mistake, refused.
if !keys.insert(key.to_ascii_lowercase()) {
return Err(EngineError::Other(format!("`{key}` is given twice.")));
}
for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
}
}
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) {
// A visible name of an 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.
let names: HashSet<String> = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect();
if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) {
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 });
}
// What was there under these keys goes, in either format, then the new chunks come.
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase())));
wb.names.extend(planned);
Ok(())
}
@@ -211,8 +241,11 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
/// 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()
pub fn keys(wb: &Workbook, prefix: &str) -> Listing<Vec<String>> {
let mut found: Vec<String> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).collect();
let more = found.len().saturating_sub(MAX_ITEMS);
found.truncate(MAX_ITEMS);
Listing { found, more }
}
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
@@ -417,10 +450,10 @@ mod tests {
// 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());
assert_eq!(keys(&wb, "orphan").found, 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"]);
assert_eq!(keys(&wb, "orphan").found, ["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\")"));
@@ -468,7 +501,7 @@ mod tests {
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());
assert!(keys(&wb, "").found.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();
@@ -482,6 +515,18 @@ mod tests {
assert_eq!(wb.names, vec![own]);
}
#[test]
fn set_many_refuses_one_key_twice() {
// Two items under one key, in any case, would leave two hidden names of one name.
let mut wb = Workbook::new();
wb.names.push(name("_gc_other_0", "\"stays\""));
let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string();
assert!(err.contains("given twice"), "{err}");
assert_eq!(wb.names.len(), 1, "nothing changed");
set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap();
assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays")));
}
#[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
@@ -489,7 +534,7 @@ mod tests {
let mut wb = Workbook::new();
wb.names.push(name("_gc_watch_00", "\"theirs\""));
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
assert!(keys(&wb, "").found.is_empty());
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
wb.names.push(own.clone());
set(&mut wb, "watch", "ours").unwrap();
@@ -505,7 +550,7 @@ mod tests {
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!(keys(&wb, "macro_").found, ["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);
@@ -525,7 +570,8 @@ mod tests {
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_");
let listing = items(&wb, "MACRO_");
let all = &listing.found;
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();
@@ -535,19 +581,41 @@ mod tests {
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");
assert_eq!(listed.found, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
assert_eq!((listing.more, listed.more), (10_002, 10_002), "the rest are counted, not lost");
// 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 few = items(&wb, "macro_").found;
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");
assert_eq!(keys(&wb, "macro_").found, ["macro_big", "macro_legacy"]);
assert_eq!((items(&wb, "").found.len(), items(&wb, "").more), (3, 0), "the other item too, none past the cap");
}
#[test]
fn an_item_over_the_size_limit_takes_no_place_in_a_listing() {
// One item too large to read sorts first and one sorts last, around MAX_ITEMS readable
// ones: every readable item is listed, and neither oversized one is counted as more.
let mut wb = Workbook::new();
let oversized = |wb: &mut Workbook, key: &str| {
for (i, formula) in chunk_formulas(&"x".repeat(MAX_ITEM_CHARS + 1)).into_iter().enumerate() {
wb.names.push(name(&format!("_gc_{key}_{i}"), &formula));
}
};
oversized(&mut wb, "macro_0");
for i in 0..MAX_ITEMS {
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
}
oversized(&mut wb, "macro_z");
assert_eq!(get(&wb, "macro_0"), None, "too large to read");
let listing = items(&wb, "macro_");
assert_eq!((listing.found.len(), listing.more), (MAX_ITEMS, 0));
assert!(listing.found.contains_key("macro_09999"));
}
#[test]
+5 -4
View File
@@ -213,7 +213,7 @@ impl Trial {
for (si, c, v) in sets {
if let Some(sh) = wb.sheet_mut(*si) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style, py: None });
keys.push((*si, *c));
}
}
@@ -329,7 +329,8 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
if !has_formula(ssi, set) {
return Err(EngineError::Other("Cell must contain a formula.".into()));
}
if has_formula(csi, chg) {
// A Python cell is a formula to Excel (`=PY(…)`): writing a number over it would lose its code.
if has_formula(csi, chg) || d.wb.sheet(csi).and_then(|sh| sh.cell(chg)).is_some_and(|c| c.py.is_some()) {
return Err(EngineError::Other("Cell must contain a value.".into()));
}
let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
@@ -342,7 +343,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let sh = cx.sheet_mut(csi)?;
let style = sh.style_id(chg);
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style, py: None });
cx.touch(csi, chg);
Ok(())
})?;
@@ -422,7 +423,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
for (c, v) in out {
let sh = cx.sheet_mut(si)?;
let style = sh.style_id(c);
sh.set_cell(c, Cell { value: v, formula: None, style });
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
cx.touch(si, c);
}
Ok(())
@@ -186,7 +186,7 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
// The full output footprint was checked above.
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
let style = sh.style_id(src);
(to, Cell { value: sh.value(src), formula: None, style })
(to, Cell { value: sh.value(src), formula: None, style, py: None })
})
})
.collect();
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
if v.is_empty() && style == StyleId::DEFAULT {
sh.remove_cell(c);
} else {
sh.set_cell(c, Cell { value: v, formula: None, style });
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
}
cx.touch(dsi, c);
}
+2 -2
View File
@@ -160,7 +160,7 @@ pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
let cell = match v.as_f64() {
Some(n) => {
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
Some(Cell { value: Value::Number(n), formula: None, style })
Some(Cell { value: Value::Number(n), formula: None, style, py: None })
}
None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?,
};
@@ -245,7 +245,7 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
let mut sheet = Sheet::new(name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
};
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
+3 -3
View File
@@ -90,14 +90,14 @@ pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
if reads_back(&iso) { iso } else { raw }
}
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
/// protected sheet. A spilled cell follows its anchor.
/// Whether `c` has a formula or Python code that must not be shown: its cell is formatted
/// Hidden on a protected sheet. A spilled cell follows its anchor.
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
if !sheet.is_protected() {
return false;
}
let at = match sheet.cell(c) {
Some(x) if x.formula.is_some() => c,
Some(x) if x.formula.is_some() || x.py.is_some() => c,
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
Some((anchor, _)) => *anchor,
None => return false,
+26 -8
View File
@@ -171,10 +171,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
cell.value = Value::Empty;
}
} else if let Some(v) = match per_cell.get(src_i) {
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
None => pattern.value(step, forward, src_cells.len()),
} {
} else if cell.py.is_none()
&& let Some(v) = match per_cell.get(src_i) {
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
None => pattern.value(step, forward, src_cells.len()),
}
{
cell.value = v;
}
cell.style = style;
@@ -296,7 +298,8 @@ impl Pattern {
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
// Formulas and Python cells copy.
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
return Pattern::Copy;
}
// Numbers.
@@ -593,12 +596,12 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((v >= prev && v > s) || (v < prev && v < s))
&& ((v >= prev && round15(v) > s) || (v < prev && round15(v) < s))
{
break;
}
let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) };
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style }));
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style, py: None }));
}
}
let sh = cx.sheet_mut(sheet)?;
@@ -657,7 +660,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
}
let sh = cx.sheet_mut(sheet)?;
for (c, out) in &writes {
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style, py: None });
}
for (c, _) in writes {
cx.touch(sheet, c);
@@ -806,6 +809,21 @@ mod tests {
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
}
#[test]
fn series_keeps_value_equal_to_stop_after_float_drift() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "linear", "step": 0.1, "stop": 1.3})).unwrap();
s.execute("cell.set", serde_json::json!({"cell": "B1", "input": "0.1"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "B1:B4", "type": "growth", "step": 3, "stop": 0.3})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let col = |c: u32, n: u32| (0..n).map(|r| sh.value(CellRef::new(r, c))).collect::<Vec<_>>();
let n = Value::number;
assert_eq!(col(0, 6), vec![n(1.0), n(1.1), n(1.2), n(1.3), Value::Empty, Value::Empty]);
assert_eq!(col(1, 4), vec![n(0.1), n(0.3), Value::Empty, Value::Empty]);
}
#[test]
fn flash_programs() {
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
+186 -11
View File
@@ -118,8 +118,143 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
}
}
/// Parses bytes that aren't encrypted.
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
/// [`enforce_python_limits`].
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
warnings.extend(crate::macros::load(&mut wb));
warnings.extend(adopt_python(&mut wb));
warnings.extend(enforce_python_limits(&mut wb));
Ok((wb, warnings))
}
/// The most result cells [`adopt_python`] rebuilds from a file's cached spills, over all its
/// Python cells (each one is also limited to [`gridcraft_model::python::MAX_RESULT_CELLS`]).
pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000;
/// Python cells from a file: a cell whose formula is `PY("…",0|1)` (a format that kept it as a
/// formula) becomes a Python cell, and a Python cell whose spilled values the file cached gets
/// them back as its result, so it shows them until it runs again. Results are rebuilt for at most
/// [`MAX_ADOPTED_RESULT_CELLS`] cells in all; the rest keep only their own cached value, and the
/// values they spilled stay as plain values. Returns the warnings.
pub fn adopt_python(wb: &mut Workbook) -> Vec<String> {
let mut budget = MAX_ADOPTED_RESULT_CELLS;
let mut skipped = 0usize;
for si in 0..wb.sheets.len() {
let Some(sh) = wb.sheets.get(si) else { continue };
let formulas: Vec<(CellRef, gridcraft_model::PyCode)> = sh
.cells
.iter()
.filter_map(|(c, cell)| cell.formula.as_ref().and_then(|f| gridcraft_model::PyCode::from_formula(&f.text)).map(|p| (c, p)))
.collect();
let max = gridcraft_model::python::MAX_RESULT_CELLS as u64;
let spills: Vec<(CellRef, RangeRef)> = sh
.spill_ranges
.iter()
.filter(|(a, r)| r.count() <= max && sh.cell(**a).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_none()))
.map(|(a, r)| (*a, *r))
.collect();
if formulas.is_empty() && spills.is_empty() {
continue;
}
let Some(sh) = wb.sheet_mut(si) else { continue };
for (c, py) in formulas {
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = None;
cell.py = Some(std::sync::Arc::new(py));
}
}
for (anchor, range) in spills {
let Some(left) = budget.checked_sub(range.count()) else {
skipped += 1;
// The cell keeps only its own value; the values it spilled stay as plain values, so
// nothing shows as a spill that no result holds, and nothing the file showed is lost.
sh.settle_spill(anchor);
continue;
};
budget = left;
let data: Vec<gridcraft_core::Value> =
range.iter().map(|c| if c == anchor { sh.cell(c).map(|x| x.value.clone()).unwrap_or_default() } else { sh.value(c) }).collect();
let Some(array) = gridcraft_core::Array::new(range.height() as usize, range.width() as usize, data) else { continue };
if let Some(py) = sh.cells.get_mut(anchor).and_then(|x| x.py.as_mut()) {
std::sync::Arc::make_mut(py).output =
Some(gridcraft_model::PyOutput { value: array.into(), type_name: "list".into(), preview: String::new(), convertible: true });
}
}
}
if skipped == 0 {
return Vec::new();
}
vec![format!(
"The file's Python results are larger than {MAX_ADOPTED_RESULT_CELLS} cells in all, so {skipped} Python cell(s) have no result until they run again; the values they spilled were kept as plain values."
)]
}
/// A file is somebody else's work: a Python cell whose code doesn't fit in Excel's
/// 8,192-character formula keeps only its cached value, and a kept result that is not a
/// well-formed array of at most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells is dropped
/// (the cell keeps its value). Either way the values the cell spilled stay as plain values
/// ([`gridcraft_model::Sheet::settle_spill`]). Returns the warnings.
pub fn enforce_python_limits(wb: &mut Workbook) -> Vec<String> {
let mut long = Vec::new();
for si in 0..wb.sheets.len() {
let Some(sh) = wb.sheets.get(si) else { continue };
let mut over = Vec::new();
let mut bad_output = Vec::new();
for (c, cell) in sh.cells.iter() {
let Some(py) = &cell.py else { continue };
if !py.fits() {
long.push(format!("{}!{}", sh.name, c.a1()));
over.push(c);
} else if py.output.as_ref().is_some_and(|o| !sane_python_output(&o.value)) {
bad_output.push(c);
}
}
if over.is_empty() && bad_output.is_empty() {
continue;
}
let dropped: Vec<CellRef> = over.iter().chain(&bad_output).copied().collect();
let Some(sh) = wb.sheet_mut(si) else { continue };
for c in over {
if let Some(cell) = sh.cells.get_mut(c) {
cell.py = None;
}
}
for c in bad_output {
if let Some(py) = sh.cells.get_mut(c).and_then(|x| x.py.as_mut()) {
std::sync::Arc::make_mut(py).output = None;
}
}
// A dropped result (or code dropped to its value) leaves the values it spilled as plain
// values, as the file showed them, with no live spill.
for c in dropped {
sh.settle_spill(c);
}
}
let Some(first) = long.first() else { return vec![] };
let more = if long.len() > 1 { format!(" (and {} more)", long.len() - 1) } else { String::new() };
vec![format!(
"A Python cell's code is longer than Excel's {}-character formula limit, so {first}{more} kept only its last calculated value.",
gridcraft_model::python::MAX_FORMULA_CHARS
)]
}
/// A Python result a file may hold: a scalar, or an array whose size matches its data and has at
/// most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells.
fn sane_python_output(v: &gridcraft_core::Value) -> bool {
match v {
gridcraft_core::Value::Array(a) => {
a.rows >= 1
&& a.cols >= 1
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS)
}
_ => true,
}
}
/// Parses bytes that aren't encrypted, as they are.
fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Xls {
@@ -147,7 +282,8 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
// Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| {
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
_ => format!("We can't open '{name}': {e}"),
@@ -160,7 +296,10 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
wb.styles.rebuild_index();
if wb.sheets.is_empty() {
// The file's macros are read after this (`macros::load`), so they come along.
let macros = std::mem::take(&mut wb.macros_read);
wb = Workbook::new();
wb.macros_read = macros;
}
// The file's band placement and cell count are derived state: rebuild them from the
// cells themselves, dropping what is outside the sheet grid (#1008).
@@ -173,17 +312,17 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
Ok((wb, report.warnings))
}
_ => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
Ok((wb, report.warnings))
}
}
}
@@ -238,9 +377,12 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
// Macros travel in hidden names.
FileKind::Xlsx => {
let named = crate::macros::with_names(wb)?;
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
.map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
@@ -250,7 +392,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Json => crate::macros::json_bytes(wb),
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
}
}
@@ -294,6 +436,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
.map_err(|e| EngineError::Other(e.to_string()))
}
/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
/// writes displayed text instead ([`save_bytes`]) and stays the default.
pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
gridcraft_core::Value::Number(n) if n.is_finite() => {
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
Some(((date, time), d)) => iso(&d, date, time),
None => n.to_string(),
}
}
gridcraft_core::Value::Text(t) => t.to_string(),
v => crate::display::value_text_as(wb, &v, "General"),
})
.map_err(|e| EngineError::Other(e.to_string()))
}
/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {
let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
if d.milli != 0 {
clock.push_str(&format!(".{:03}", d.milli));
}
match (date, time) {
(true, true) => format!("{day}T{clock}"),
(false, true) => clock,
_ => day,
}
}
pub(crate) fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
@@ -580,10 +755,10 @@ thread_local! {
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
pub(crate) struct ScriptSandbox(());
pub struct ScriptSandbox(());
impl ScriptSandbox {
pub(crate) fn enter() -> Self {
pub fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(())
}
@@ -599,7 +774,7 @@ impl Drop for ScriptSandbox {
/// thread is being torn down).
fn script_sandbox_check(path: &str) -> Result<()> {
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
return Err(EngineError::Other(format!("{path}: scripts, and macro steps without the permission, can't read or write files")));
}
Ok(())
}
+54 -10
View File
@@ -19,6 +19,7 @@ fn copied(wb: &Workbook, range: &str) -> String {
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
#[test]
fn save_fails_upfront_when_the_temp_name_is_taken() {
// Another local user who can write the directory must not be able to point the
@@ -290,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() {
assert_eq!(wb.sheets[0].name, "Sales");
}
#[test]
fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() {
let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap();
let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap();
for file in ["dates.csv", "dates.txt"] {
let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}");
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}");
assert!(warnings.is_empty(), "{file}: {warnings:?}");
}
let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap();
assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
@@ -446,17 +461,17 @@ fn csv_writes_displayed_text() {
(styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0"));
let sh = wb.sheet_mut(0).unwrap();
let cells = [
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }),
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }),
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }),
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }),
("E1", Cell { value: Value::text("007"), formula: None, style: text }),
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }),
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }),
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }),
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }),
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }),
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }),
("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }),
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }),
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }),
("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }),
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }),
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }),
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }),
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }),
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }),
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }),
];
for (a, c) in cells {
sh.set_cell(CellRef::parse(a).unwrap(), c);
@@ -560,3 +575,32 @@ fn content_wins_over_a_mismatched_extension() {
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
}
}
/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)`
/// wrapper as the formula inside when GridCraft calculates every function it calls (including
/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a
/// Google-only function.
#[test]
fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
use gridcraft_core::{CellRef, Value};
let mut wb = Workbook::new();
let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let wrapped = [
("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)),
("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")),
];
for (at, text, value) in &wrapped {
let cell = gridcraft_model::Cell {
value: value.clone(),
formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))),
..Default::default()
};
sh.cells.set(CellRef::parse(at).unwrap(), cell);
}
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap();
let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))"));
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
}
+144 -5
View File
@@ -15,10 +15,12 @@ pub mod display;
pub mod fill;
pub mod io;
pub mod links;
pub mod macros;
pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
pub mod recent;
pub mod record;
pub mod sample;
pub mod selection;
@@ -37,6 +39,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_te
pub use gridcraft_calc as calc;
pub use gridcraft_core as core;
pub use gridcraft_formula as formula;
pub use gridcraft_math as math;
pub use gridcraft_model as model;
pub use gridcraft_numfmt as numfmt;
pub use selection::Selection;
@@ -54,6 +57,12 @@ pub enum EngineError {
NoDocument,
#[error("{0}")]
Other(String),
/// The command would replace something; run it again with `overwrite: true` to go ahead.
#[error("confirm: {0} (run again with `overwrite: true`)")]
Confirm(String),
/// Active content (a macro) asks to run; run it again with `approve: true` to allow it.
#[error("approve: {0} (run again with `approve: true`)")]
Approve(String),
#[error("internal error in `{0}` (the workbook was kept as it was): {1}")]
Internal(String, String),
}
@@ -97,6 +106,9 @@ struct OpenGroup {
selection: Selection,
/// The redo stack when the group began (a rollback puts it back).
redo: Vec<HistoryEntry>,
/// Where the recording was when the group began (`None` when nothing was recording): a
/// rollback takes back the steps recorded since, as it takes back their edits.
recorded: Option<(u64, u64)>,
}
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
@@ -203,6 +215,12 @@ pub struct DocState {
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
/// where it wrote what was already there.
pub touched: Option<Vec<(String, CellRef)>>,
/// While a macro step runs: the structural edits its command made (the sheet's name, the
/// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do.
pub edits: Option<Vec<(String, gridcraft_formula::adjust::Edit)>>,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
@@ -231,6 +249,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
if !older.macros.shares(&newer.macros) {
bytes = bytes.saturating_add(older.macros.estimated_bytes());
}
bytes
}
@@ -283,7 +305,9 @@ impl DocState {
calc.rebuild(&wb);
Self::with_calc(wb, calc, path, title, trust)
}
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
fn with_calc(mut wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
// Equations arrive without a layout (files never store one); painting reads only the cache.
wb.refresh_equations();
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
@@ -303,6 +327,8 @@ impl DocState {
content_kind: None,
scripts_trusted,
trust,
touched: None,
edits: None,
go_to_recent: vec![],
}
}
@@ -368,6 +394,10 @@ pub struct Prefs {
pub formula_bar: bool,
/// The statistics the status bar shows for a selection (right-click the status bar).
pub status_bar: StatusBarStats,
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
/// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel.
#[serde(deserialize_with = "recent::lenient")]
pub recent_functions: Vec<String>,
}
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
@@ -402,6 +432,7 @@ impl Default for Prefs {
user_dictionary: vec![],
formula_bar: true,
status_bar: StatusBarStats::default(),
recent_functions: recent::defaults(),
}
}
}
@@ -446,6 +477,9 @@ pub struct Session {
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
journal_sizes: Vec<usize>,
journal_bytes: usize,
/// While a dry run runs (the footprint, `macro.check`), its commands are not journaled: the
/// dry run is put back, and journaling it would evict the person's own oldest entries.
pub(crate) journal_paused: u32,
pub ui_requests: Vec<UiRequest>,
/// Format painter source style (sticky when double-clicked).
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
@@ -460,6 +494,17 @@ pub struct Session {
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was
/// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the
/// recording hook.
pub(crate) script_ran: Option<Vec<(String, Json)>>,
/// Macros the person allowed to run in this session: (document uid, the macro's content as
/// canonical JSON, from [`macros::fingerprint`]).
pub macro_approvals: std::collections::HashSet<(u64, String)>,
/// The macro being debugged, between debugger actions.
pub macro_debug: Option<macros::debug::Debugger>,
/// Where the macro recorder started (beside `recorder`, its buffer).
pub macro_recording: Option<macros::recorder::RecordingStart>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
pub draw_tool: String,
pub draw_color: String,
@@ -522,6 +567,17 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit
/// is allowed. Front ends ask before opening an editor, so typing into a locked cell never
/// leaves a draft that can't be entered (and so blocks saving).
pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option<String> {
let d = self.active()?;
let sh = d.wb.sheet(sheet)?;
if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked {
return None;
}
cmd::locked_cell_error(sh, at, &d.unlocked_ranges)
}
/// Opens a workbook in a new window, which becomes the active one.
pub fn add_document(&mut self, d: DocState) -> usize {
let uid = d.uid;
@@ -535,6 +591,10 @@ impl Session {
if i < self.docs.len() {
let d = self.docs.remove(i);
self.groups.retain(|g| g.doc != d.uid);
// A macro being debugged in this workbook ends with it; its group just went.
if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) {
self.macro_debug = None;
}
self.windows.retain(|w| w.doc_uid != d.uid);
if self.active_window.is_some_and(|w| self.window(w).is_none()) {
self.activate_recent();
@@ -547,6 +607,7 @@ impl Session {
pub fn new_workbook(&mut self) -> usize {
let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1);
let mut wb = Workbook::new();
wb.props.author = self.prefs.user_name.clone();
for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) {
let name = wb.next_sheet_name();
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
@@ -586,6 +647,16 @@ impl Session {
}
}
/// Cuts the journal back to its first `keep` entries, with its size bookkeeping.
pub(crate) fn truncate_journal(&mut self, keep: usize) {
if self.journal_sizes.len() == self.journal.len() {
self.journal_sizes.truncate(keep);
self.journal_bytes = self.journal_sizes.iter().sum();
}
self.journal.truncate(keep);
self.recording = self.recording.map(|r| r.min(self.journal.len()));
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
@@ -598,7 +669,25 @@ impl Session {
if let Err(why) = (spec.enabled)(self) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// A debugger whose undo group something else ended locks nothing any more (`macro.debug`
// itself says that the run had ended).
if id != "macro.debug" {
macros::debug::drop_if_ended(self);
}
let query = !spec.journal && !spec.undoable;
// While a macro is being debugged its undo group is open, and its workbook is locked:
// between steps only the debugger, queries and commands that look around run there
// (`macros::debug::LOOKS_AROUND`). Anything else waits, undoable or not (saving, view
// modes, stopping Record Actions, which saves a script), also when another command runs
// it. Only the run's own steps go ahead. Other workbooks stay editable.
let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping()));
if locked && !macros::debug::runs_while_locked(id, query) {
return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into()));
}
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
// A dry run inside this command that a panic cuts short leaves journaling paused and its
// touch log open; the panic branch puts both back.
let paused_before = self.journal_paused;
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.
@@ -608,7 +697,6 @@ impl Session {
// 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)));
@@ -669,7 +757,16 @@ impl Session {
}
d.calc.rebuild(&d.wb);
}
if self.journal_paused != paused_before {
self.journal_paused = paused_before;
for d in self.docs.iter_mut() {
d.touched = None;
}
}
if self.depth == 0 {
// Only the `macro.debug` call itself can lose the run's place: a command nested
// in a step that panics is an error of that step, which the run handles.
macros::debug::recover(self);
self.trim_history();
self.group_step = None;
}
@@ -687,13 +784,34 @@ impl Session {
// 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.
// A call that opened a dialog and changed the workbook too (a script with an edit before
// a menu-style step) is recorded: the edit happened, and the dialog's confirming call
// is a step of its own.
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 changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb)));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id))
&& let Some(rec) = self.recorder.as_mut()
{
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
// A toggle (Bold) is recorded with the state it set, as Excel records
// `Bold = True`: playing it back sets that state rather than flipping it again.
let params = match &result {
Ok(r) if params.is_object() => record::with_toggle_state(id, cmd::journal_params(&params), r),
_ => cmd::journal_params(&params),
};
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
// A script or macro run is kept with the steps it ran, so editing or deleting the
// script or macro before the recording stops does not change what was recorded.
if let Some(ran) = self.script_ran.take()
&& kept
&& matches!(id, "automate.runScript" | "macro.run")
{
rec.attach_ran(ran);
}
}
if self.journal_paused == 0 {
self.push_journal(id, cmd::journal_params(&params));
recent::note(&mut self.prefs.recent_functions, id, &params);
}
self.push_journal(id, cmd::journal_params(&params));
}
// 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.
@@ -808,6 +926,7 @@ impl Session {
wb: d.wb.clone(),
selection: d.selection.clone(),
redo: d.redo.clone(),
recorded: self.recorder.as_ref().map(record::Recorder::mark),
};
let token = GroupToken { id: g.id, doc: g.doc };
self.next_group = self.next_group.saturating_add(1);
@@ -904,6 +1023,11 @@ impl Session {
d.undo.truncate(g.start);
d.redo = g.redo;
d.revision += 1;
// The steps recorded while the group was open go with their edits (all of them when the
// recording started inside the group).
if let Some(rec) = self.recorder.as_mut() {
rec.cut_back(g.recorded.unwrap_or((0, 0)), g.doc);
}
// 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));
@@ -935,6 +1059,21 @@ impl Session {
self.depth
}
/// Runs a command as a command in progress would (one level deeper), for tests of what a
/// nested call may do.
#[cfg(test)]
pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result<Json> {
self.depth = self.depth.saturating_add(1);
let r = self.execute(id, params);
self.depth = self.depth.saturating_sub(1);
r
}
/// Whether the token's undo group is still open (nothing has ended or rolled it back).
pub fn group_is_open(&self, token: &GroupToken) -> bool {
self.groups.iter().any(|g| g.id == token.id)
}
/// 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)

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