* xlsx: stop memory amplification from declared sizes (#2173)
Hyperlinks: drop targets over 8,192 bytes before copying them into each cell, cut ScreenTips to 255 characters, and count the 10,000-cell cap across all <hyperlinks> blocks of a sheet. Conditional formats: cap the sqref copies made by extra rules at 1M ranges per sheet. Styles: build each custom number format once and share its Arc; cut font names to 255 characters. Shared formulas: keep cached values for dependents of over-long masters, and charge each expansion against a 2 GiB per-workbook budget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Import the hyperlink caps in sheet_read/parts after the merge
Upstream switched parts.rs to an explicit super:: import list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* xlsx: charge conditional-format ranges per sheet; cap number format codes (#2173)
- read_cf keeps a cumulative per-sheet count of the range cells retained
rules hold, instead of multiplying the current block by the rule count,
and charges only rules that are kept.
- Custom and inline number format codes longer than 255 characters (Excel's
limit) are dropped with a warning; their cells show General.
- The shared NumFmt map is built only for ids a cell or differential
format refers to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* xlsx: keep over-long number format codes as stored (#2173)
an_overlong_number_format_code_does_not_stop_the_open requires a code
past 255 characters to round-trip; sharing one NumFmt per referenced id
already removes the amplification. Keep the referenced-id sharing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* xlsx: bound only what a shared formula costs beyond an ordinary cell; count characters for the length warning
Review follow-up for #2197 (@skylartaylor):
- Each dependent of a shared formula now pays only its cost beyond an ordinary formula cell
(SHARED_FORMULA_FREE_COST, 4 KiB by shared_cost), and the per-workbook budget for that excess
drops from 2 GiB to 256 MiB. Many ordinary shared formulas still all expand; a long master
copied into thousands of cells stops at the budget and keeps the cached values.
- The over-long-master warning now uses the parser's own test (bytes, then characters), so an
unparseable multibyte master no longer gets a false length warning.
Refs #2173
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* Paste: whole-column paste and validation without per-cell blowup
edit.paste of a whole-column or whole-row copy walked every cell of the
copied block (up to the whole sheet) and, for each one, re-cut every
validation rule and pushed a one-cell rule. Over a 2M-cell block that is
quadratic and the fuzz soak aborted on 34-68 GB allocations.
- Paste visits only cells that hold something in the source or the
destination; blank onto blank changes nothing (and with an operation
stays blank, as in Excel, instead of becoming 0).
- Validation pastes as ranges: the pasted rectangle is cut once out of
the destination's rules and each source rule comes along as one rule
over its translated parts (Skip Blanks or not, as Excel does).
- Whole columns and rows paste whole, so destination content further
down the columns is replaced, as in Excel.
- A paste that does not fit on the sheet is refused, as Excel does,
instead of pasting part of the block (whole columns below row 1, whole
rows right of column A, a whole column transposed, a block at XFD).
- Paste Link, the one paste that writes every cell, is capped at
1,048,576 cells; tiled validation at 100,000 ranges.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Paste: visit destination cells once per paste, add link, transpose and tiling tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Paste: visit link-only cells so a cut keeps their hyperlink
* Paste validation: Skip Blanks keeps the destination rule under a blank source cell with no rule
* Paste validation: Skip Blanks collects clearing cuts only when a rule is under the paste, merges them into runs, and limits only generated areas
* Paste validation: annotate the clearing cut list
* Qualify paste validation context type
* Fix paste-special clippy warnings
* engine: batch validation range cuts
* Fix range type inference
* Annotate validation range index type
* Avoid shadowing test session helper
* Keep validation rows after the final cut
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* ft-121.7.9: apply Set precision as displayed in calculation (#1343)
With the option on, a formula result (and each element of a spill) is
rounded to its cell's number format before it is stored and before other
formulas read it. Numbers typed, pasted or reformatted while it is on are
rounded at once, and turning it on through formulas.calculationOptions
{precisionAsDisplayed} rounds every number constant, as Excel does.
The rounding is NumberFormat::round_as_displayed in the numfmt crate,
which shares the renderer's digit logic (fixed, scientific, fraction).
General, date/time, text, digitless and conditional formats keep the full
value, as observed in Excel through COM. Nothing changes when the flag is
off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ft-121.7.9: round conditional formats by the sign's section under precision as displayed (#1343)
Excel does round formats with a condition when "Set precision as
displayed" is on; the PR kept them at full value. Observed through COM
with synthetic values (and in gridcraft-corpus
excel/precision-displayed/pad-on-conditional-sections.xlsx): conditions
are ignored and the sign picks the section, the first for positives and
the second for negatives. `[<1]0.00;0` shows 2+1/3 as 2 but stores 2.33,
and stores -10/3 as -3.
Two more observed details: with a condition and a single number section,
negatives keep the full value (`[<1]0.00`), unless an `@` section follows
(`[<1]0.00;@` rounds them by the first section); and a completely empty
section passes to the next one (`0.0;;0.000` stores -2.333 as -2.333,
`;0.0` stores 2.333 as 2.3), for formats with or without a condition.
Display is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine: recalculate spill anchors when a spill cell is reformatted under precision as displayed
round_edited_as_displayed only looked at stored-cell changes, but a spill
destination lives in Sheet::spill, so reformatting it (or its row or column) left
the anchor out of the changed set and the element unrounded. Add each spill anchor
whose range holds a changed cell or a changed row/column style to the changed set.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* numfmt: drop a cast the fraction merge made redundant
* precision as displayed: keep locked constants on protected sheets, round new sheets, fix manual-mode comment
* precision as displayed: name the input sheet in the new-sheet histogram test
* precision as displayed: use six values in the new-sheet histogram test
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* UI: a dialog's reference boxes belong to the sheet it was opened on, recorded when it opens rather than at the first pick
* UI: a click on the cell already selected writes it into the reference box being picked into
* Solver: references split into areas only at commas outside quoted sheet names, through one splitter shared with the reference boxes
* Solver: a constraint splits at its relation only outside quoted sheet names, in constraint text and in a model loaded from cells
* Solver: Save Model counts a constraint's areas outside quoted sheet names, and reads a number on the right side as one value whatever its separators
* UI: a Ctrl+click that follows a link is no pick of the cell already selected
The press on the grid tells the reference box being picked into that a
cell was selected only in the branches that select: Shift+click, Ctrl+click
that adds a cell, and a plain click. A Ctrl+click on a link's text follows
the link and selects nothing, so a link back to the selected cell no longer
replaces the box's text.
* UI: while a reference box picks, a second quick click on a cell picks it
A click on the same cell within the double-click interval started editing
the cell and skipped the pick, also when the earlier click came before the
box was clicked into. While a reference box is the one grid selections
fill, the double-click handling is skipped and the click picks.
* Docs: reference boxes take a second quick click as a pick; a followed link is none
* UI: while a reference box picks, a click on a link or checkbox picks its cell
A link was followed (Go To moved the selection into the box, and a web
link's question replaced the dialog) and a checkbox was toggled. While a
reference box is the one grid selections fill, a click only picks the cell
clicked, with or without Ctrl. The test of the Ctrl+click on a link back to
the selected cell becomes part of a test of links on the same sheet, a web
link and a checkbox.
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); GridCraft keeps the download hook, the open size limit, the workbook-saved rule and the author.
data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; Bridge holds a Transport (Parent or Page) and shares the save, dirty and ready logic. Unframed ?host=parent still gets no bridge. Hosting guide documents both modes.
German showed "Einfügen" for Paste and again for Insert in the cell menu
("Einfügen…") and the row and column header menus ("Einfügen"). The cell
menu's Insert… now reads "Zellen einfügen…", as Excel's German menu does.
The header menus' Insert gets its own key, "Header Menu|Insert", German
"Zellen einfügen"; the plain "Insert" stays as the Insert dialog's title
and button use it, and every other catalog gets the new key with the text
it shows today.
A test reads the three menus' items from menus.rs and checks that no two
read alike in German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Charts: draw every doughnut series as a ring (#1827)
* Charts: submit doughnut hole size and first slice angle when the drag ends (#1827)
---------
Co-authored-by: alosslessdev <alosslessdev@users.noreply.github.com>
* About: credits tables fit without side-scrolling and their columns resize
The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.
The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.
The About dialog opens 820 points wide, so every column fits at its natural width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Let every credits column be dragged, and refit changed tables
The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.
A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.
fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refit credits tables on what they measure, not the fitted widths
Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.
It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.
The table now compares the natural widths and the clip rect's width,
which scrolling does not move.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- crates/xlsx/src/read.rs: main's names.unique() kept with this branch's
(sheet, read_controls) result from read_sheet.
- crates/ui-egui/tests/it/keytips_generated.rs: regenerated with
cargo xtask keytips from the merged keytips.json (413 sequences).
- docs/file-format-parity.md, docs/ui-parity.md: header and revision
history keep main's entries and this branch's form-controls entries.
The 10,000-character header spec was bounded, but each &A, &F or &Z
added the whole sheet name or path again, and a JSON workbook's sheet
name has no length limit. A field that would take a section past
64 KiB is now left out.
edit.fillSeries with dateUnit "weekday" stepped one calendar day at a
time, so a large step cost work proportional to its size for every
cell. It now uses add_weekdays, which moves whole weeks arithmetically.
* functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit
The delimiter matcher compared every delimiter, duplicates included, at
every position of the text. Delimiters are now deduplicated and grouped
by the character a match is anchored on, and a search that would
compare more than 100 million characters returns #VALUE!.
* functions: pass float literals in the delimiter test
The importer kept every cell of a row and only charged their text to
the 64 MiB allowance when the row finished, so one row of long cells
could hold up to 16,384 MiB before being refused. Text is now charged
as each cell finishes; the row charges only its repeats.
* functions: MODE counts through an index, FREQUENCY bins by binary search
MODE/MODE.SNGL/MODE.MULT searched the list of distinct numbers seen so far
for every input, and FREQUENCY scanned the sorted bins for every data value,
so both were quadratic in computed arrays. MODE now keys counts by value
(-0 folded into 0, NaN never merged) and keeps first-occurrence order;
FREQUENCY finds the first bin at or above each value with partition_point
over the order it already sorts. Results are unchanged.
* address review: FREQUENCY keeps NaN data in the overflow bin and NaN bins empty
* calc: list each dependent once when a change spreads
* calc: check the dependency closure against a walk of every cell
Random graphs of single-cell, narrow and wide range reads (some removed or unsettled), random seeds and changed areas: the closure with shared taken records and area lookups equals the one listing every dependent of every changed cell.
* calc: cap the text one & over arrays may build in all
* address review: concatenation over arrays stops at the byte budget without reserving the whole broadcast
Every source record scanned the filter's whole selection, so a large
selection multiplied by many records was slow even when nothing passed.
Each filter's labels are lowercased into a set once per computation.
wrap_lines split an over-long word by re-measuring and copying the
rest of the word for every piece it cut, and only checked its line
limit once the word was done, so one long word in a narrow text box
cost word length x pieces. Each new word also re-measured the whole
line built so far.
Line and piece widths are now running sums of glyph widths (the same
sum text_width makes), so every character is measured once, and the
line limit is checked while a word is being split. The lines produced
are unchanged; a test compares them with the old wrapper.
Importing a workbook whose sheets share a name rescanned every sheet for
every suffix tried, so a file of repeated names opened in cubic time.
SheetNames keeps the assigned names and the next suffix per stem.
Upstream folded cmd/insert/*.rs back into insert.rs, so the merge left
the policy check in a file that is no longer compiled. Restore that file
to upstream and apply the check in the live open_link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #3223: sniff the first 64 KiB for a web page and skip JSON and files over the text-import cap, so
every CSV/TSV is no longer decoded in full before the size check.
- #3253: SYLK relative offsets use checked arithmetic (no overflow on R[9223372036854775807]); at
most 1024 column-width records are kept; regression test.
- #3254: a single selected merged cell searches the whole sheet, as one selected cell does.
- #3252: the formula bar's height cap is half the room below the ribbon, not half the window, so
the worksheet stays visible in a short window (CodeRabbit Major).
- #3239, #3244, #3252, #3268: es/de/cs/zh/ko/ru/pt entries for the new interface strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses review comment 4456174694: the drag/expand cap was half the
window, so in a 400 pt window a ~195 pt bar left the grid about 1.6 pt
under the status bar and sheet tabs. The cap is now also limited by
the room left under the ribbon, keeping the status bar, sheet tabs,
column headings and two rows below the bar.
Test: ui-egui formula_bar_expand::an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ui.key Enter or ui.click on OK reached egui as ordinary input and
could confirm the openLink prompt. A pass that carries synthetic input
now keeps the prompt open and opens nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the follow-up in review 5753664551: the protection test had
no range exception, so a regression that refused locked notes and
links inside an Allow Edit Range would have passed. The new test
clears a link in a range without a password, is refused a note in a
password range, then clears both once that range is unlocked.
Test: engine tests::clearing_links_and_notes_keeps_allow_edit_ranges.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses review comment 4455670315:
- Clear Hyperlinks and Clear Comments removed links and notes on
locked cells of a protected sheet: protection compares stored cells
only, so their removal went unseen (for stored cells too, and now
for unstored ones since the whole range is cleared). `clear` now
refuses a locked cell's note or link with locked_cell_error, which
keeps Allow Edit Ranges and session-unlocked ranges.
- A removed note or link on a cell with nothing stored wasn't added
to the changed cells, so a recorded macro's footprint missed it.
Tests: engine tests::clearing_links_and_notes_respects_sheet_protection,
tests::clearing_links_and_notes_records_their_cells.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses review comment 4455436875: `clear` clipped each range to the
sheet's used range (stored cells) before removing links and notes, so
a link from a file on an empty cell outside it was left in place, and
nothing was removed on a sheet with no cells. Notes and links are now
cleared over the whole range; cells and merges still only within the
used range.
Test: engine tests::clear_hyperlinks_removes_links_on_cells_with_nothing_stored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
point_move wrote a bare reference, so on another sheet Shift+arrow after a header
drag turned 'Other sheet'!B:D into B:E, which reads as the formula's own sheet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In Page Layout view the "Click to add data" pages are faded after their cells are
painted, which all but hid the band on them. The band goes on again over the fade.
Tests also cover Page Layout view and frozen panes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a drag over C:E, point_cell held C and no anchor was set, so Shift+Right
moved the wrong end and gave D:E. The drag now records the pointer's end as the
moving one and the opposite corner as the anchor, so it gives C:F, and rows work
the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses review comments 4455115540 and 4455115557. The parser stores
`'Sheet'!Name` as `Sheet!Name` with the sheet already unquoted:
- Evaluator::name split it at the first `!`, so a series named
`'a!b'!Title` showed #NAME?.
- chart_name unquoted it again, trimming spaces, so a chart reference
to `' Sales '!Data` was saved as `Sales!Data`.
Both now split at the last `!` and look the sheet up as is.
Tests: chart tests::a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves,
xlsx tests::chart_book_names::chart_names_keep_spaces_around_a_quoted_sheet_name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a wide table the eye loses its place following a row from the active
cell. File > Options > General gets "Highlight the active cell's row", off
by default: a tint across the whole row of the active cell, over the fills
and under the text, in Normal and Page Layout views. It is a preference for
every sheet and workbook, so nothing goes into the file, the workbook does
not become dirty and there is no undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A press on a header while a formula waits for a reference inserted C:C, but the
drag that followed did nothing, so dragging from column C to E left C:C. The drag
now follows the pointer, as it does over cells, in either direction and for rows
too, keeping the sheet qualifier, the earlier arguments of a Ctrl press and the
far end of a Shift press.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
table_columns restarted its suffix search at 2 for every duplicate
header, lowercasing and hashing every candidate already in use, so a
table of repeated headers was exported in quadratic time. The next
suffix to try is now remembered per lowercased base.
Addresses review comment 4453674677: after a frame drew both controls,
turning off the horizontal scroll bar or the sheet tabs left
app.grid.hscroll / app.grid.tab_split holding the old rectangles.
sheet_tabs now clears both before any visibility return.
Test: tab_split::hiding_the_controls_clears_their_cached_rectangles
toggles both options on one live harness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3250:
- "Clear parked keytip requests when their window becomes hidden": a
window hidden from another window (View › Hide while it isn't in
front) kept the request it had parked, and showing it again opened
the control. follow_active_window now drops the waiting request of
every parked window that is hidden.
- "Correct the no-badge description in the parity table": only Alt+I,
D, V and T (no ribbon tab) paint no badges.
Test: legacy_keytips::a_window_hidden_from_another_window_drops_its_parked_request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3231, "Reparse consumed separators when a
field is not quoted": parse_tsv read "a<TAB>b"x as one quoted field,
then fell back to its raw text without the separator it had consumed,
so the cells "a, b"x and c pasted back as two. The new copy (which no
longer quotes a cell holding only a quote) makes that text. A field
whose closing quote doesn't end it, or that never closes, is now read
again as unquoted text, so its tabs and line breaks separate cells.
The text read again is bounded by the input's length, so hostile input
stays linear.
Test: a_field_that_is_not_really_quoted_keeps_its_separators.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3250, "Clear deferred key-tip requests
when parking a hidden window": a hidden window isn't drawn, so a request
it parked would fire whenever it was shown again (the Symbol gallery
opening long after the keys). Parking a hidden window now drops it; the
rest of its interface state is kept.
Test: legacy_keytips::a_hidden_window_drops_its_waiting_key_tip_request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3229, "Use the same translucency
threshold as committed ink": the preview judged the pen by its width in
points, the committed stroke by its width on screen, so at 50% a 12 pt
highlighter turned opaque once committed and at 200% a 6 pt pen turned
translucent. Both now use chartview::ink_color, which goes by the
stroke's width in points, so the two agree and neither changes with
the zoom.
Test: ink_render::ink_translucency_follows_the_pen_not_the_zoom.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3250, "Keep deferred requests with their
window": the request waiting for the next frame was shared across
windows, so the root window, drawn first, could take a second window's
Alt+I, S. It is now parked with each window's interface state and moved
into the one-frame request when that window's ui begins.
Test: legacy_keytips::a_waiting_key_tip_request_stays_with_its_window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3250, "Expand the ribbon for a same-tab
control request": with the Insert tab chosen but the ribbon collapsed,
Alt+I, S went to the same-tab branch and the Symbol gallery was never
drawn. A collapsed ribbon is now expanded like a tab switch, and the
request waits for the frame that draws the control.
Test: legacy_keytips::alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edit.find and edit.replace take within: selection, which searches only
the selected cells; Find Next then moves the active cell through them
and keeps the range selected, as Excel does. The Find and Replace
dialog uses it whenever more than one cell is selected (Excel searches
a selected range; one selected cell still means the whole sheet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3250, "Open a symbol picker instead of
re-parsing the cell": the legacy key ran insert.symbol with no symbol,
which set the active cell to its own text again (re-reading it: text
could turn into a number, a Python cell lost its code). It now routes to
the ribbon's Symbol control: a control key tip whose control is on
another tab shows that tab first and hands the request to the next
frame, when the control is drawn, and the Symbol button opens its
gallery on its key tip. Nothing is inserted until a symbol is picked.
Test: legacy_keytips::alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone
(keytips regenerated with cargo xtask keytips).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A small reader for the SYLK text format: cell values (numbers, text,
booleans, errors), formulas converted from R1C1 to A1 as Excel does
(relative/absolute references, ranges, whole rows and columns, shared
formulas) with their cached results, number formats (P / F;P) and
column widths (F;W). Import only; saving as .slk says so. The sheet is
named after the file, and damaged records are skipped with a warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cmd/Ctrl+V dropped egui's PasteImage event, and the ribbon/menu Paste only read
text, so screenshots and images copied in a browser reported "Nothing to paste".
Both now encode the pixels as PNG (bounded to 16384 px per side, 64 Mpx) and run
insert.picture as a floating picture anchored at the active cell, as Excel does.
Text on the clipboard still wins. New host service: Services::paste_image (arboard).
Fixes#699, #3018, #3182.
The bottom edge of the formula bar is a resize handle: dragging it sets
the height in whole lines (up to half the window; one line collapses
the bar). Expanding again (the chevron, Ctrl+Shift+U) returns to the
dragged height instead of a fixed five lines. The drag previews live
and commits once through view.expandFormulaBar {lines}, which is also
scriptable; the height is kept with the UI preferences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds 43 legacy sequences to keytips.json (Insert, Edit, Format, Data,
View, Tools menus and File > Page Setup), each running the command or
dialog the modern key tip already uses. Alt+I, D, V and T are implicit
levels with no ribbon tab; badges are not painted under them, as in
Excel. The generator checks Options (a Backstage page) by its page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Right-clicking a cell with a link only offered "Link…", whose dialog was
titled Insert Hyperlink, so changing a link wasn't obvious. As in Excel,
the cell menu of a linked cell now offers Edit Hyperlink… (the same
dialog, filled with the link and titled Edit Hyperlink), Open Hyperlink
(as clicking the link: a reference selects its cell, a web or e-mail
address asks first) and Remove Hyperlink (edit.clearHyperlinks). A cell
without a link keeps "Link…".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3238, "Limit the remove_personal_info
doc comment to XLSX saves": only the XLSX writer leaves out the author
and company; the workbook's own serialization still holds them. The
comment now says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three places still split a `Sheet!Name` reference and stripped the
sheet's quotes by hand: the chart resolver (is a series name a defined
name?), the XLSX chart writer (the name's cache) and the macro editor's
example references. The first two left a doubled quote in the name, so
a series named `'Bob''s'!Title` (a name on the sheet "Bob's") wasn't
found and showed that text instead of the name's value; they also split
at the first `!`, so a quoted sheet holding one broke. All three now use
core's split_sheet, the one reading formulas use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up: a column-handle drop on the last frozen column while
scrolled, which fails if freeze_boundary measures with x(c + 1), and a check
that frozen A1's text lies inside its clip, not only that the clip is A1.
Paste Special only knew the letters F, V, T, C and E. Excel gives every
option one: A (All), N (Validation), X (All except borders), W (Column
widths), R (Formulas and number formats), U (Values and number formats);
O, D, S, M, I for the operations; B for Skip blanks; and L for the
Paste Link button, which pastes at once. One table now maps each letter
to what it does; Enter still pastes with the chosen options.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel can hide the sheet tabs, the horizontal scroll bar and the
vertical scroll bar of a workbook window (File › Options › Advanced ›
Display options for this workbook), saved with the workbook. GridCraft
already read and wrote these settings (#3057) but always showed all
three, and had no command or option for them.
New commands view.showSheetTabs, view.showHorizontalScrollBar and
view.showVerticalScrollBar {on?} (toggling without `on`, undoable,
saved with the workbook), and a "Display options for this workbook"
section in File › Options. The tab bar follows them: without the tabs
the scroll bar takes the bar, without the scroll bar the tabs do, and
with neither the bar isn't shown; without the vertical scroll bar the
grid takes its width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3223:
- "Skip leading HTML comments before the prefix check": a page starting
with an exporter's <!-- comment --> was read as CSV text. Complete
leading comments are now skipped before looking for the page's start.
- "Preserve nonbreaking spaces in HTML text imports": split_whitespace
turned New York into "New York". The decoded cell text is
used as is (ordinary whitespace is already collapsed), so the
non-breaking spaces stay, as Excel keeps them.
Test: a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel's per-workbook privacy setting (Trust Center › Privacy Options ›
Remove personal information from file properties on save, set by the
Document Inspector) is saved as <workbookPr filterPrivacy="1"/>, and
while it is on Excel leaves the author and company empty on save.
GridCraft dropped the attribute, so a cleaned workbook came back with
the setting off, and it wrote the author and company back.
Workbook.remove_personal_info now keeps the setting (read and written
as filterPrivacy), and while it is on the writer leaves dc:creator and
Company out. Other properties are written as before. Note authors and
lastModifiedBy are the rest of the issue (the latter overlaps #881).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid took a column's right edge to be where the next column starts,
`x(col + 1)`. At the freeze line the next column is in the scrolled pane
and moves with the scroll, so everything measured that way inside the
frozen columns shrank as the sheet scrolled right:
- text overflowing from a frozen cell was clipped at the scrolled
column's edge, until only a character or two showed;
- the frozen columns' gridlines stopped short, with a stray vertical
line drawn inside them;
- a merged range's borders under the last frozen column ended early;
- the freeze handle measured the last frozen column wrongly when
dropped while scrolled.
Geo gains `x_end`/`y_end` (a column's or row's own far edge) and
`same_col_pane`, and every such measurement uses them. Text overflows
only into empty cells of its own pane. In Page Layout view the
gridlines now stop at each page's last column instead of running into
the margin.
Fixes#2544
Addresses CodeRabbit review on #3226, "Skip hidden corner destinations"
and "Keep merged destinations inside the selected area": with column D
hidden, Ctrl+. on B2:D3 made D2 active; with B1:C1 merged and column C
selected, it made B1 active, outside the selection. A corner in a hidden
row or column, or one whose merged cell starts outside the selected
area, is now passed over like a locked corner on a protected sheet.
Test: next_corner_skips_hidden_corners_and_stays_inside_the_selected_area.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3223:
- "Decode HTML before parsing its cells": a page in Windows-1252 read as
lossy UTF-8, so "Café" lost its é. The page is now decoded as a CSV is
(BOM, UTF-8, else Windows-1252/1256); decode_text is exported for it.
- "Keep visible cell text when csv_as_text is enabled": opened as text, a
cell with x:num took the recorded number ("4,50" became "4.5"). The
recorded number is used only when values are read.
- "Preserve the imported format when styling a header": a date in a <th>
lost its date format when made bold and centred. Header styling now
builds on the style the CSV reader gave the cell.
Test: a_web_page_table_keeps_its_characters_text_and_header_formats.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3226:
- "Check the next corner against protected-sheet selection rules": on a
sheet protected without Select Locked Cells, Ctrl+. could make a locked
corner active. It now goes on to the next corner it may select.
- "Keep the active cell at a merged cell's anchor": a corner inside a
merged cell made that cell active instead of the merge's top-left
cell. The corner now lands on the top-left cell, and corners in the
same merged cell are one stop.
Selection::next_corner_by takes where each corner leads (or None to skip
it); next_corner keeps its plain behaviour.
Tests: next_corner_skips_corners_the_sheet_protection_does_not_let_you_select,
next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workbook saved by Excel in R1C1 reference style says so in
<calcPr refMode="R1C1"/>. GridCraft didn't read the attribute, so
opening and saving such a file switched it back to A1 style for the
next person who opened it in Excel. The calculation settings now keep
it (CalcSettings.r1c1, serde default false) and the writer puts it back;
A1 stays the default and isn't written. GridCraft itself still shows
and takes A1 references; the R1C1 display and entry are the rest of
the issue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy wrapped any cell that held a double quote in quotes (and doubled
its quotes), so copying `say "hi"` into another app gave
`"say ""hi"""`. Excel quotes only text with a tab or line break; a cell
with just quotes goes on the clipboard as it is.
Pasting text read a field that starts with a quote as quoted even when
its closing quote doesn't end the field, so `"hi" there` (which Excel
copies as it is) pasted as `hi there`. A field is now quoted only when
its closing quote is followed by a tab, a line end or the end of the
text; otherwise it is the text as written, as Excel pastes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While dragging with a pen, the stroke under the pointer was always
painted blue at 2 pt, whatever pen or highlighter was chosen, so the
preview and the committed ink disagreed (a highlighter previewed as a
thin blue line). The preview now uses the colour and width draw.stroke
stores (pen blue and 2 pt by default, 0.5-40 pt, whole points) and the
translucency Ink shapes are painted with above 8 pt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page margins are kept as f32 and were widened to f64 when written,
so a 0.3-inch margin was saved as left="0.30000001192092896" (and 1.1
as 1.100000023841858). Each margin now goes through its shortest
decimal form first, so the file says 0.3, as Excel writes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel's Next Corner of Selection (Ctrl+., Control+. on a Mac) moves the
active cell clockwise to the next corner of the selected area (top-left,
top-right, bottom-right, bottom-left) and keeps the area selected; the
new active cell is also the anchor, so Shift+arrows then move the
opposite corner. GridCraft had no such command.
New command selection.nextCorner (Selection::next_corner) bound to
Ctrl+. on every platform. A one-row or one-column area goes end to end;
a single cell stays where it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workbook with checkboxes saved by GridCraft made Excel repair it
("Repaired records: Format from /xl/styles.xml (Styles)") and drop every
checkbox. The feature property bag part GridCraft writes for checkboxes
left out extRef="XFComplementsMapperExtRef" on the XFComplements bag,
the bag the cell formats' xfComplement indexes into. With it, Excel
opens the file without repairing and shows the checkboxes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3221, "Check the output size before
with_subtotals builds subtotal lines": the size limit was checked only
after every subtotal line existed. A row with 4000 key columns at total
depth 4000 nested with_subtotals 4000 levels deep and overflowed the
stack (the process aborted) before the late check could refuse it.
with_subtotals now works without recursion: each line gets an id for its
key prefix at every level, a stable sort by those ids keeps each group
together in first-appearance order, and one walk adds the subtotals.
The output size (with the header and total lines when present) is
checked while the groups are counted, before any subtotal line is built.
Test: groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3216, "Keep the selection on the sheet it
describes": moving Two!B1:B2 with Sheet1 active selected A1:A2 on
Sheet1, where nothing moved. The selection belongs to the active sheet,
so range.move now keeps it when the moved cells are on another sheet; a
move on the active sheet still selects the moved cells.
Test: drag_and_drop_on_another_sheet_stays_on_that_sheet (now also
checks the selection).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web apps' "Export to Excel" often writes an HTML table and names it
.xls. GridCraft read such a file as CSV text, so it opened as lines of
markup in column A. Excel opens the table (after warning that the
format and the extension don't match).
Opening a file whose bytes are a web page with a table now reads the
first table as a sheet, through the clipboard HTML table reader: the
cells go through the CSV reader, so values read as they do in a CSV
(and stay text with csvAsText); <th> cells are bold and centred; the
sheet takes the page's <x:Name> or the file's name. A page that isn't
named .htm/.html opens with a warning saying what it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3219, "Recognize the legacy Count
Numbers caption when the function changes": workbooks saved before
Count Numbers was captioned "Count of" carry "Count Numbers of Val"
(optionally with a number suffix). pivot.valueSettings now treats that
as an automatic caption of a Count Numbers field, so changing the
function renames it; a custom name stays.
Test: legacy_count_numbers_caption_follows_the_function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses CodeRabbit review on #3216:
- "Pass the resolved sheet to the paste step": a sheet-qualified move
(`Two!B1:B2` with Sheet1 active) was checked on Two but pasted on the
active sheet, replacing its data unasked. range.move now calls
paste_special with the range's sheet.
- "Check spilled values in the drop destination": cells showing a dynamic
array's spilled values hold no cell of their own, so the drop replaced
them without the prompt. They now count as data, as in Excel
(ISBLANK of a spill cell is FALSE, COUNTA counts it).
Tests: drag_and_drop_on_another_sheet_stays_on_that_sheet,
drag_and_drop_asks_before_replacing_spilled_values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GROUPBY's total_depth only ever added one Total line. In Excel a depth
of N adds a subtotal line for each group of the first N - 1 key columns
(after the group, or before it when the depth is negative) and labels
the last line "Grand Total"; a depth greater than the number of key
columns, or above 1 with field_relationship 1 (a flat table), is
#VALUE!. The subtotals aggregate the group's source rows, so AVERAGE and
COUNT subtotals are right too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Show Values As › Rank Largest to Smallest counted every larger sibling,
so ties skipped ranks: 5, 4, 3, 3, 4 ranked 1, 2, 4, 4, 2 where Excel
gives 1, 2, 3, 3, 2. It now counts the distinct larger values.
A new Count Numbers value field was captioned "Count Numbers of Units";
Excel captions it "Count of Units", as GridCraft's XLSX reader already
names a value field saved without a name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workbook with an in-cell picture and a dynamic-array formula wrote
the XLDAPR futureMetadata extension URI in upper case
({BDBB8CDC-…}), and Excel refuses such a workbook. The metadata part
written for dynamic arrays alone, which Excel opens, spells the same
URI in lower case; the rich-value writer now does too.
Together with #1533 (content types and the XLRICHVALUE URI) this lets
Excel open a workbook with an in-cell picture and a spilled formula.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging the selection's border onto cells that hold data replaced them
without asking, so dragging column B onto column A silently destroyed A.
Excel asks "There's already data here. Do you want to replace it?" first
(its "Alert before overwriting cells" option, on by default).
The drop now runs a new `range.move {range?, to, overwrite?}` command:
a cut and paste that returns a confirm question when the destination
holds data other than the moved cells; the grid shows it with OK and
Cancel, and OK runs it again with `overwrite: true`. A refused move
leaves the sheet and the clipboard as they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- sheet.moveColumns resolved its target sheet from `sheet`, or a
`Sheet!` range or cell, but its two protection checks were passed
only the `sheet` parameter, so a qualified range moved columns of
a protected sheet while the checks authorized the active one. Both
checks now get the resolved sheet index.
- check_protection compared only cell content, so a sort or duplicate
removal moving an unlocked cell with equal content onto a locked one
left the content as it was but made the cell editable. The commit
check now also refuses a change that leaves a previously locked cell
unlocked (effective lock, row and column formats included).
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.
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.
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.
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".
* 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>
The pass-through writer put GridCraft's control entries before the last <controls> close tag of
the kept fragment, so a kept mc:AlternateContent with separate <controls> elements in its Choice
and Fallback got them in the Fallback, which the reader (and an application that knows x14)
doesn't read: on reopen the control came back from its VML shape alone, without its name and
its properties part. The entries now go into the <controls> element the reader reads: the
Choice's when the Choice holds one, else the first one (a plain fragment, or a Fallback alone);
a Fallback's element is left as the file had it. Tags are matched by local name, with any
namespace prefix.
Test: a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content (the saved
Choice element holds the kept list box and the modelled slider, the Fallback only the list box;
the file reads back with the slider's name, link and value from its worksheet entry and
properties part, and the list box kept).
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.
The pass-through writer found the kept element's end by the literal `</controls>`, so a producer
that prefixes the main namespace (`</x:controls>`) got a second <controls> element for the
modelled controls, which the worksheet schema does not allow. The close tag is now found by its
local name with any prefix. No other literal tag search is left in rebase.rs; the ones in read.rs
(VML `<o:idmap>`, `<v:shapetype>`, `ObjectType="Note"`) rely on the fixed VML prefixes and are
main's code.
Test: a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix (a kept
`<x:controls>` with a list box and a modelled slider save as one controls element holding both,
and the file reads back with the slider modelled and the list box kept).
A button could be given a fixed input range for any macro name; pressing it then passed the range
to macro.run as `inputs`, which check_params refuses for a macro without inputs ("it takes no
inputs"), so such a button failed on every press. Now insert.button and control.assign refuse a
fixed input when the named macro exists and declares no inputs, naming the macro; a macro the
workbook does not have yet is not checked, and the assignment is kept as given. On open, a stored
fixed input for a macro without inputs becomes the selection: the file itself assigns the macro
on the selection, and the stored range would fail every press.
Tests: a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned (insert and assign are
refused with the reason in any letter case; a macro with an input and a macro not yet defined
take the range; a stored fixed input comes back as the selection). format_and_assign and
a_kept_button_action_comes_back_only_on_the_same_button give their macro an input.
ROADMAP.md: header combined (form controls first, then main's Python mode and Python cells notes); the form-controls progress-log and revision rows above main's Python rows.
crates/engine/src/io.rs: open_plain's doc comment names both (the button actions kept in hidden names, and the adopted Python cells); after the macros it loads the button actions, then adopts the Python cells and enforces their limits.
crates/ui-egui/src/theme.rs: this branch's sheet control colours and main's PY tag colour.
docs/file-format-parity.md: header combined (form controls first, then main's Python cells note); the form-controls revision row above main's Python row.
docs/ui-parity.md: header combined (form controls first, then main's Python mode note; ribbon catalog count 281 / 296); the form-controls revision row above main's Python row.
Both sides added a kind of floating object (equation, form control); both are kept everywhere.
ROADMAP.md: header combined (form controls first, then equations), catalog 281 / 296 as `cargo xtask parity` measures, the form-controls progress-log and revision rows above main's equation rows.
crates/engine/src/cmd.rs: both spec lists (controls and equation).
crates/engine/src/cmd/extra.rs: the arrange params docs list both kinds.
crates/engine/src/cmd/extra/objects.rs: main's `found` match and `anchors_of` on ObjKind gain the Control arms; the unlocked check takes the parsed kind directly.
crates/engine/src/cmd/insert.rs: the object params docs and the kind error name both kinds.
crates/engine/src/tests.rs: both test modules.
crates/engine/src/tests/objects.rs: every_kind returns six objects (equation at L18, slider at P18) and the anchor lists carry both.
crates/model/src/features.rs: ObjKind has Equation and Control, ALL lists six kinds, name() both.
crates/model/src/sheet.rs: both imports, both accessor arms, the order test inserts equations (6, 8) and controls (13, 15) and documents controls after slicers with equations counted as shapes.
crates/model/src/workbook.rs: main's refresh_equations and this branch's free-id doc comment.
crates/ui-egui/src/chartview.rs: both doc comments combined.
crates/ui-egui/src/objects.rs: both OpenAction variants and arms.
crates/ui-egui/src/ribbon/insert.rs: the import list is the union.
docs/control-protocol.md: the kind list names equation and control; main's equation sentences and this branch's form-controls sentence.
docs/file-format-parity.md: header combined; the xlsx row carries both additions; the form-controls revision row above main's equation rows.
docs/parity-checklist.md: regenerated with `cargo xtask parity` (281 / 296).
docs/target-app-parity.md: header combined; catalog 281 / 296; the Insert objects row carries both additions; the form-controls revision row (277 / 292 → 281 / 296) above main's.
docs/ui-parity.md: header combined with catalog 281 / 296; main's move-and-resize row with equations plus the form-controls row; the form-controls revision row above main's.
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).
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.
* 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 ` ` 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>
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>
without_controls dropped a whole child of a kept <controls> element as soon as one control in it
was modelled, so an mc:AlternateContent holding a scroll bar (modelled) and a list box (left to
pass-through) lost the list box's worksheet entry on save, while its VML shape and properties
part stayed. The controls are now taken out one by one, in the Choice and the Fallback branches
alike, by the shapeId the control reader reads (an attribute, in either quote style); the wrapper
stays while a control remains and goes only when it is empty, and a child with none of ours is
kept unchanged. strip_shapes already works per shape, so the kept control's shape stays, and its
properties part follows its relationship as before.
Test: an_unsupported_control_beside_a_modelled_one_in_one_alternate_content_is_kept (after open
and save the scroll bar is written once, the list box is still in <controls> in both branches
with its VML shape and its properties part, and the file reads back with one modelled control and
the kept one).
without_controls found the controls to drop from a kept <controls> fragment by splitting its
text on `shapeId="`. The control reader takes the attribute through the XML parser, which also
accepts `shapeId='1025'`, so a producer's single-quoted control was modelled, its VML shape taken
out of the kept drawing, and the control itself still kept: a save wrote it twice, the kept copy
pointing at a shape that no longer existed. The kept child is now parsed and its controls found
with the reader's own helpers (controls_of_child, shape_number), so what the reader modelled is
what is dropped. The shape type check of the VML merge (`<v:shapetype id>`) accepts either
quote style too, so a kept drawing that defines our shape type with single quotes is not given
a second definition.
Test: a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too (the control is read once,
nothing of it is kept, the saved sheet holds one control and no single-quoted copy, the VML one
shape, and the file reads back with one control).
write_link skipped the write only when the linked cell held a constant equal to the value; a cell
with a formula was always written, so Format Control's OK (which sends the value back every
time) replaced the formula with a constant even when the formula showed that value, and on a
protected sheet with the linked cell locked a rename was refused. The value the cell shows is now
compared whether or not it holds a formula: a write that changes nothing is skipped, and a real
change still replaces the formula with the constant, as in Excel.
Test: a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows (insert and Format
Control with the value the formula shows keep the formula, also on a protected sheet with the
cell locked; a changed value writes the constant).
A click on a form control that form_controls::interact leaves to the grid (Ctrl or Cmd+click,
which selects the control; a click on a selected control, which moves it) fell through to the
slicer tile branch, which looks only at slicers, so a control placed over a slicer's value tile
had the tile toggled and the slicer selected before the generic object handling could select or
drag the control. The slicer tile branch now runs only when the press lands on a slicer by the
same test the generic path uses (the selected object's handle first, then the topmost object), so
the z-order decides once; a control over a tile, and a selected object's handle over one, keep
the generic path. Charts, pictures and shapes lie beneath slicers in that order, so they were not
affected.
Test (kittest): a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click (a button
over the first tile; Cmd+click selects the button and the slicer's filter is unchanged; a click
on the selected button still toggles nothing; an uncovered tile still toggles).
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.
* 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>
origin/main's tree is identical to 05851de4, the head of pull request 470 that a4faa909 already merged, so every conflict is the same change on both sides and this branch's side is kept:
ROADMAP.md: this branch's side (main's #470 header text and rows are already in it, with the form-controls header text and rows).
crates/engine/src/cmd/edit/paste_special.rs: this branch's side (identical #470 change, plus the control kind).
crates/engine/src/cmd/extra.rs: this branch's side (identical #470 change, plus the control commands).
crates/engine/src/cmd/extra/objects.rs: this branch's side (identical #470 change, plus the control kind).
crates/engine/src/cmd/insert.rs: this branch's side (identical #470 change, plus the control kind in object.move).
crates/engine/src/cmd/sheet/tabs.rs: this branch's side (identical #470 change, plus a copied sheet's controls).
crates/engine/src/tests/objects.rs: this branch's side (both added the file; this branch's has #470's tests and the copied-sheet controls test).
crates/model/src/features.rs: this branch's side (identical #470 change, plus the form-control types).
crates/model/src/sheet.rs: this branch's side (identical #470 change, plus the controls field and its accessors).
crates/model/src/workbook.rs: this branch's side (identical #470 change, plus the controls' ids).
crates/ui-egui/src/chartview.rs: this branch's side (identical #470 change, plus hit-testing controls).
crates/ui-egui/src/grid/cursor.rs: this branch's side (identical #470 change, plus the control cursor).
crates/ui-egui/src/grid/interact.rs: this branch's side (identical #470 change, plus the form-control interaction).
crates/ui-egui/src/objects.rs: this branch's side (both added the file; this branch's has #470's code and the control kind).
docs/control-protocol.md: this branch's side (main's size-cap sentences are already in it, with the control kind paragraph).
docs/target-app-parity.md: this branch's side (main's #470 header text and row are already in it, with the form-controls header text and row).
docs/ui-parity.md: this branch's side (main's #470 header text and row are already in it, with the form-controls header text and row).
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.
* 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".
With a slider or spin button focused, the Space key reached the grid and toggled a checkbox cell,
and the space it types then started a cell edit over the toggle (Escape cancelled the edit but
not the toggle). Now the typed space is dropped while a control is focused and the active cell
is a checkbox, as it is for a focused button: Space on a cell does what it does with no control
focused and nothing more, a checkbox cell toggles once with no edit, any other cell starts an
edit with a space. The path with no control focused is unchanged.
Test (kittest): space_on_a_checkbox_cell_toggles_it_once_while_a_slider_is_focused (toggled once,
no editor, the slider's value and focus unchanged; a second Space toggles back).
docs/ui-parity.md says so.
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.
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.
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).
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).
The space a Space press types was dropped while any control was focused, because Space presses
a focused button. A slider or spin button doesn't take Space, so with one focused Space neither
worked the control nor started a cell edit, while every other character did. Now the space is
dropped only for a focused button; on a slider or spin button it reaches the grid like any other
character and starts a cell edit. The control keeps the keyboard, as the control last worked:
Space is a key it doesn't take, exactly like a letter, and letting go on Space alone would make
Space differ from every other printable key.
Test (kittest): space_starts_a_cell_edit_while_a_slider_or_spin_button_is_focused (a cell edit
starts with a space, the control's value is unchanged, Escape cancels, the arrows still step it).
docs/ui-parity.md says so.
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.
A click on a button focused it, and the focus stayed while the grid moved the cell cursor with
the arrows, Tab or Delete, or opened a cell's menu with a right-click, so a later Enter or Space
pressed the button again without a new click, and Space could not start a cell edit. Now a key
press the focused button doesn't take (any key but Space, Enter and Escape, or one with Ctrl,
Cmd or Alt) gives the keyboard back to the grid, and any press that isn't a left click on the
grid, a right-click on a cell included, moves the focus off a control. A slider or spin button
keeps the keyboard as the control last worked.
Tests (kittest): a_button_gives_the_keyboard_back_when_the_grid_takes_a_key (arrow, then Enter
moves the cursor and does not press; arrow, then Space starts a cell edit; Cmd+arrow takes the
keyboard too) and a_right_click_on_a_cell_takes_the_keyboard_from_a_button. docs/ui-parity.md
says so.
A button's command ran through the macro policy and the file sandbox, but not through the macro
runner's request filter, so a file-borne button assigned `link.open {url}` opened the page once
the generic trust question was answered. Now `link.open` is refused for buttons up front, when it
is assigned and when a file brings it in, with the wording of the other denied commands; and
after a press runs its command, the UI requests it queued are checked as the macro runner checks
a step's: a dialog or a web page makes the press fail with the reason and rolls the group back,
a cell edit is dropped, and messages for the person stay queued.
Tests: link.open joins a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in
(assign, insert and a file-borne press refuse it; no OpenUrl request is left), and
a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs (insert.link with no
parameters is refused with the dialog wording and its request dropped; the filter refuses a page,
drops a cell edit and keeps messages). docs/macros.md says what a button's command may not do.
An entry of the hidden name without `name` or `at`, or with something other than text there,
skipped those checks (is_none_or) and could pin its action onto whatever button sat at the stored
index. The writer always stores both keys, so such an entry is now dropped like an unparsable one
(is_some_and); the doc comment says so.
Test: a_kept_action_without_the_buttons_name_or_anchor_is_dropped (an entry without `name`,
without `at`, with a number for `name` and with null for `at` gives the button no action and the
hidden name is removed; the writer's own entry still comes back).
crates/engine/src/cmd/insert.rs: the object.move params line keeps both the `control` kind (this branch) and the size-cap note (w/objects).
crates/engine/src/tests/objects.rs: both sides' new tests kept, the copied-sheet controls test first, then the two oversized-object tests.
crates/ui-egui/tests/it/chart_data_table.rs: w/objects' own fix (21a27a76, `selected_object = Some(ObjRef::new(ObjKind::Chart, id))`) replaces the equivalent select_object_id line of dcefd523.
docs/control-protocol.md: this branch's `control` kind paragraph, followed by w/objects' sentences on the object.move and chart.set size cap.
crates/model/src/sheet.rs: git merged both sides' `data_table: None` lines into the push_object Chart literal (dcefd523 and 21a27a76 made the same change at different places); the w/objects line is kept, the dcefd523 one dropped.
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".
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.
`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.
`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).
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.
load_action_names gave every kept action back to a button the file assigned no macro. A button
that ran a macro with a fixed input range or an output cell, whose macro another application
removed while keeping the button's name and place, so came back with the assignment. Now a kept
macro action comes back only onto a button the file still assigns that macro, and a kept command
only onto a button the file assigns no macro, as the function's comment already said.
Tests: a_macro_assignment_removed_in_another_application_stays_removed (the saved file with the
controlPr macro attribute and the VML FmlaMacro removed reopens with no action and no hidden name;
unedited, with the whole action), a_macro_assigned_in_another_application_replaces_a_stored_command
(the file's macro wins over a kept command), and the cleared case in
a_kept_button_action_comes_back_only_on_the_same_button. macros.md and file-format-parity.md
describe the hidden name and the reopen rule.
ROADMAP.md: both header changes (form controls first, then main's chart data tables), the form-controls progress-log and revision rows on top of main's rows.
docs/file-format-parity.md: both header changes (form controls first, then main's page order and chart data tables), the form-controls revision row on top of main's rows.
docs/macros.md: both header changes (form buttons first, then main's step-editor close rule).
docs/target-app-parity.md: both header changes (form controls and catalog 280 / 296 first, then main's ToolPak, page order and data tables), the form-controls revision row on top of main's rows.
docs/ui-parity.md: both header changes (form controls first, then main's Trace Error and page order; catalog count 280 / 296), the form-controls revision row on top of main's rows.
crates/model/src/sheet.rs: the push_object test helper (w/objects, b874f586) gets main's new Chart field, data_table: None, so the model crate's tests compile.
crates/ui-egui/tests/it/chart_data_table.rs: main's new test selects its chart through select_object_id, the one selection of w/objects, instead of the selected_chart field that w/objects replaced.
ROADMAP.md, docs/chart-parity.md, docs/gaps.md, docs/target-app-parity.md, docs/ui-parity.md: the header change notes combine this branch's note with main's, dated today; main's revision and progress rows stay and this branch's rows are re-dated on top. Code merged without conflict.
* 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>
* 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>
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.
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.
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.
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.
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.
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
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
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
A sheet copy renumbers its form controls from the same free ids as its charts, pictures, shapes
and slicers, so when a file's slider carries the largest id the copies still get distinct ids;
free_object_ids counts the controls' ids as used, so 3 and u32::MAX held by controls are skipped.
crates/model/src/workbook.rs: w/objects' next_object_id over free_object_ids replaces this branch's loop; the form controls' ids join the used ids it collects, as the branch's loop had them; the branch's test that a control's id is in the sequence stays beside w/objects' tests.
crates/engine/src/cmd/sheet/tabs.rs (auto-merged): the count of free ids a copied sheet asks for includes its form controls, which the copy renumbers from the same iterator; without it the controls would all take u32::MAX.
Each listed control read and parsed its properties part again, and every read is charged to the
package's 1 GB budget, so a file whose controls all named one part spent the budget on it: the
controls after that were dropped and the parts read later (notes, threaded comments) failed with
TooLarge. The parsed part is now cached by target; a part that fails to read or isn't a
formControlPr is cached as nothing and not read again. Test: 13,000 controls naming one 200 KB
part keep the sheet's 10,000 controls and the note read after them.
The right-click that selects the button also opens its menu at the pointer, so the plain click
that follows could have gone to the menu instead of the button. The test now closes the menu with
Escape and asserts it is closed before clicking; the dialogs test asserts that choosing a menu item
closes it.
`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).
Each listed control looked its properties part up with a scan of the sheet's relationships, so a
sheet at the 10,000-control cap compared ids a hundred million times. The relationships are
indexed by id once, as the VML shapes already were; the first of a repeated id still wins.
A button's command is refused at Insert, at Assign Action and, when a file's hidden name carries
it, at press time (file.save, file.saveAs with a path, file.open, app.commands, macro.run,
automate.runScript, control.press); nothing runs and no file is written. A slider or spin button
whose linked cell is locked on a protected sheet refuses the change through control.setValue,
Format Control and Insert, and keeps its value; a link to another sheet obeys that sheet's
protection; an unlocked linked cell takes the value.
ROADMAP.md: both headers combined (form controls first, then main's entries); the form controls progress-log and revision rows re-dated 2026-11-01 on top of main's rows; the branch's duplicate floating-objects rows dropped in favour of main's.
docs/file-format-parity.md: header combined the same way; the form controls revision row on top of main's rows.
docs/target-app-parity.md: header combined; the form controls revision row (catalog 276 / 292 -> 280 / 296) on top of main's rows; the duplicate chart-resizing row dropped in favour of main's.
docs/ui-parity.md: header combined, its catalog count set to 280 / 296; the form controls revision row on top of main's rows; the duplicate floating-objects row dropped.
crates/ui-egui/tests/it/keytips_generated.rs: regenerated with cargo xtask keytips (370 sequences).
crates/model/src/sheet.rs: the exhaustive push_object test helper and the two-of-every-kind list gained the Control kind, which main's new test module did not know.
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.
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.
ROADMAP.md: main's header and rows kept; this branch's progress-log and revision rows re-dated 2026-11-01 on top; the Change note names both.
docs/gaps.md: main's §13 Solver rows kept; this branch's §4 row re-dated 2026-11-01 on top; the Change note names both.
docs/target-app-parity.md: main's Solver and Insert Function rows kept; this branch's chart-handles row re-dated 2026-11-01 on top; the Change note names both.
docs/ui-parity.md: main's Function Library and Esc rows kept; this branch's floating-objects row re-dated 2026-11-01 on top; the Change note names both.
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.
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>
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.
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.
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.
* 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>
* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
* 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>
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>
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.
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.
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>
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>
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>
The Running section says macro.check takes `output`, but the
macro.check signature further down left it out. It now lists
`output?`, and says the reported output anchor is `output` when given.
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>
The reader took the sheet's first VML drawing relationship. A sheet
with pictures in its header or footer has a second VML drawing
(<legacyDrawingHF>), and when that relationship came first the control
shapes were not found: VML-only controls were skipped, and listed
controls lost their caption and macro. The drawing is now the one
<legacyDrawing r:id> names, as pass-through already does.
The focused control took every pressed key event, the system's key
repeats included, so holding Enter or Space on a focused button ran its
macro or command again for each repeat. A repeat now still belongs to
the button (it doesn't reach the grid) but doesn't press it; arrows,
Page Up and Page Down held on a slider or spin button keep stepping.
The footprint counts a deleted form control as a conflict, but the
check made as each step runs counted only charts, pictures and shapes.
After a footprint that stopped early (at Solver, or an approved file
step), a later step could delete a control with no question. It now
counts controls too, so such a step stops the run under fail and asks
under ask.
control.format and control.assign refused to change a locked control on
a protected sheet, but object.move, object.delete,
object.setAnchorMode, arrange.bringForward, arrange.sendBackward and
arrange.align changed it all the same. They now refuse it with the
same message; an unlocked control still moves, and other kinds of
objects are unchanged.
file-format-parity.md, target-app-parity.md and ui-parity.md gave form
controls a new timestamp line without a matching revision-history row;
each now has one. The file-format-parity.md timestamp line keeps the
earlier change it replaced, as the other docs do.
Each control listed in <controls> looked for its VML shape by scanning
every shape, so a sheet of 10,000 controls took over 8 s to open. The
shapes are now indexed by number once, and the same sheet opens in
about 0.4 s.
A sheet with more controls than a sheet keeps (10,000), properties
parts that are not XML, are missing or hold numbers no control can take
(1e400, NaN, -inf, 2^64), shape ids that are not numbers, anchors far
outside the grid, and oversized names and links. Reading keeps exactly
the cap, every control passes the model's rules, and a save and reopen
keeps them all.
Some producers give a VML shape a named id (`id="Button 1"`) and keep
its number in `o:spid`. The form control reader looked for the number
only in `id`, so a control listed in <controls> lost its caption and
macro (they live in the VML), and a control known only from the VML was
skipped. Shapes are now matched by `id`, else by `o:spid`, as
pass-through already does when it leaves the modelled shapes out.
The button actions xlsx has no place for (commands, fixed inputs,
output cells) are kept in a hidden name keyed by sheet and the
control's place on the sheet. When another application added, removed
or reordered controls before saving, opening the file gave the action
to whichever button now had that place, and could replace a macro the
file itself gave that button.
Each entry now also holds the button's name and anchor cell, and is
restored only on a button with that name and cell whose own action from
the file does not contradict it: a stored macro action only on a button
that still runs that macro, a stored command only on a button the file
gives no macro.
Pressing a button whose command reads or writes a file asked "Run
file.exportCsv from 'Button 1'? It writes a file." without saying which
file, so the person could not tell what they were approving. The
question now ends with the command's `path` in parentheses; control
characters show as spaces and a path longer than 200 characters is
shortened in the middle.
The Insert tab gains Button, Slider and Spin Button beside Cell
Controls › Checkbox, which is unchanged. Each arms placement: a drag on
the grid draws the control at that size (a tall drag makes a vertical
slider), a click places it at its default size, and Escape disarms. Key
tips (N B U, N S L, N S B) place one at the active cell. A new button
opens Assign Action, as Excel opens Assign Macro.
form_controls.rs draws the controls in the sheet tokens, so they look
the same in both themes: buttons with hover and pressed states, sliders
with a track, a filled part and a thumb (and the value beside the thumb
while it is dragged), spin buttons with two arrows, stacked or side by
side. A plain click works a control: dragging the thumb, clicking the
track (a page), clicking or holding an arrow (it repeats after 0.4 s),
pressing a button (it runs on release over it). A drag or a hold is one
undo step. The control last worked takes the arrows, Page Up and Page
Down, Home and End, and Space or Enter for a button. Ctrl or Cmd+click
or a right-click selects a control to move or resize it; a right-click
opens the object menu, which for a control adds Edit Text and Assign
Action (buttons) and Format Control before Delete.
Double-clicking a selected control opens its editor. On a right-to-left
sheet a control moves as a whole and works where it is drawn.
Assign Action runs a macro (from the list or typed, on the selection or
a fixed range, with an optional output cell) or a command (searched
among those a macro may run, with JSON parameters). Format Control edits
the value, limits, incremental and page change, cell link, orientation,
name and the locked and print flags, checking the same rules as the
engine. A button whose action needs approval, or whose macro would write
over data, asks in a prompt whose buttons press it again with the
answer.
New theme tokens (thumb, hover, pressed, fill, focus ring) are in the
contrast test; Button, Slider and Spin icons are drawn in code; the new
labels are translated in every interface language. The catalog gains
Developer › Controls (280 / 296).
Buttons, scroll bars (sliders) and spin buttons are written the three
ways the published formats describe: a control properties part
(xl/ctrlProps/ctrlPropN.xml, formControlPr with objectType, fmlaLink,
min, max, inc, page, val and horiz), a <control> in the sheet's
<controls> inside mc:AlternateContent with its anchor, print and locked
flags and a button's macro="[0]!Name", and a VML shape with
x:ClientData. Notes and controls share one VML drawing per sheet; shape
ids come from 1024-id blocks listed in the drawing's idmap (after the
blocks kept header and footer drawings use), notes first, so each
control's shapeId names its VML shape.
Reading prefers the properties part, takes the caption from the VML
text box and falls back to the VML alone for files that only have it.
Values are clamped to whole numbers 0..30000 and repaired to pass the
model's rules.
Form controls of other kinds (check boxes, list boxes, drop-downs) stay
with pass-through: the reader returns the shape numbers it modelled,
and pass-through keeps the rest of the <controls> element and VML
drawing without them. On save, modelled controls join the kept
<controls> element and take a fresh shape block in the kept drawing, so
a sheet with both round-trips without duplicates. Properties part names
are reserved so kept parts of other sheets don't collide with them.
Button actions xlsx has no place for (commands, fixed inputs, output
cells) travel in a chunked hidden name keyed by sheet and position, and
are taken back out on open. The streaming sheet parser now splits only
shallow <row> elements, so an anchor's <xdr:row> inside <controls> stays
in the tree.
A sheet holds form controls as floating objects of a new kind
(ObjKind::Control): buttons that run a macro or one command, and sliders
and spin buttons that hold a whole number from 0 to 30000 and write it
to a linked cell. They take part in everything other objects do: ids
from the workbook sequence, object.move, object.delete and
object.setAnchorMode, the Selection Pane and Bring Forward / Send
Backward, anchors that follow row and column edits, fresh ids on a
copied sheet, and links that follow inserts, deletes, cut-moves, sheet
renames and sheet deletes like chart series.
Commands (cmd/controls.rs): insert.button, insert.slider,
insert.spinButton, control.format, control.assign, control.setValue,
control.press and the control.list query. Each numeric rule (0 <= min <
max <= 30000, 1 <= step <= max - min, page >= step for sliders) is
refused with its own message, and values are clamped and snapped to the
step. control.setValue writes the linked cell through cell.set inside
one undo group, so dependents recalculate and one Undo restores both;
every commit moves linked controls to what their cells hold.
control.press runs a button's macro through macro.run, or its command
through the macro policy with the document's trust, so a button in a
workbook opened from a file asks before it runs anything. Like a macro
step, the command runs inside the scripts' file sandbox unless it is an
approved file command. Macros never press controls, and macro.rename
keeps buttons pointing at the renamed macro.
macro.run, macro.check and the debugger take `output`, which replaces
the macro's output anchor for that run (a button that sends the output
elsewhere). The policy and the approval go by the stored macro; the
footprint, the conflict rule and the per-step check work on the cells
`output` names. A value that isn't reference text of at most 1,024
characters, or names no cell, is refused before anything runs.
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.
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.
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.
`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
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.
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).
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.
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.
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.
`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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
`parse_streaming` counts every element it attaches to a parent against
MAX_XML_NODES, but the cells of a worksheet are attached to their `row`
while the row is open, and the row is then handed to the callback and
dropped. So the count grew with every cell of a sheet although the tree
never held more than one row, and a workbook with a sheet of about one
million ordinary cells (12,274 rows by 103 columns) was reported damaged
after 6,310 rows since #2413. Shared strings, streamed by `si`, had the
same exposure: a table of about a million plain strings, or fewer
rich-text ones.
The count now returns to its value at the start of a split element when
that element is handed out, so a streamed part of any size passes while a
part that keeps a million elements in its tree is still refused.
Regression test: 5,001 streamed rows of 100 cells are read whole; the same
part kept in a tree is refused.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
.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.
link.open is reachable by the control channel, CLI, MCP and workbook
scripts, so it could open https://attacker/?d=<cell data> without the
user seeing it. By default (LinkPolicy::Confirm) it now refuses web and
e-mail addresses with an error and opens no dialog; references still
navigate and other schemes stay refused. A host may opt in with
LinkPolicy::Allow (Rust API only, no command sets it).
The UI's Open Hyperlink prompt no longer runs a command on OK: it calls
Session::open_confirmed_link with the address it showed, kept in a
private field, so ui.dialog.confirm can't press OK and ui.dialog.set
can't swap the address behind the prompt text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1.**A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2.**Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3.**Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4.**Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5.**Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6.**Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7.**Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8.**`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9.**Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10.**Keep secrets and personal details out** of the repository, logs and requests.
11.**CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12.**Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13.**Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14.**Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15.**No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1.**Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2.**Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3.**Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4.**Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5.**Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6.**Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
-`ISC`
-`MIT`
-`BSD-2-Clause`
-`BSD-3-Clause`
-`Apache-2.0`
-`OFL-1.1`
-`Ubuntu-font-1.0`
-`Bitstream-Vera`
-`CC0-1.0`
-`CC-BY-4.0`
-`CC-BY-SA-4.0`
-`LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# GridCraft — instructions for agents
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.
`plan/` is gitignored (local only).
Read [`ROADMAP.md`](ROADMAP.md) and [`docs/gaps.md`](docs/gaps.md). `plan/` is an optional gitignored notebook (status, execution notes, `plan/excel/*.md`). Do not ask for it and do not stop when it is absent. Screenshots of Microsoft software stay in that notebook and are never committed.
## Assets and iconography — absolute rule
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
@@ -20,7 +68,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,13 +79,12 @@ 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.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
@@ -46,12 +93,12 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- **For UI work, look at the result** (snapshot PNG) and, when available, compare with `plan/excel/observed-ui.md`. Without that optional notebook, verify the intended behaviour against the task and committed documentation.
- macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- 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,16 +109,15 @@ 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
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.
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
@@ -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 |
| `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 |
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.
`plan/` is gitignored (local only).
## Assets and iconography — absolute rule
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
- Every asset in the repo is original work by GridCraft contributors, or public domain / CC0, or open source / Creative Commons that allows redistribution (or licensed open source by the contributor who created it).
- Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md) (author, source, licence). `cargo xtask assets` enforces it. Adding an asset without a row is a bug.
- Prefer art generated in code: the UI icon set (`crates/ui-egui/src/icons.rs`), table and cell style palettes, themes and sample workbooks are drawn/defined in code and original.
- Never copy Office's icons, ribbon artwork, theme XML, table-style definitions, templates, clip art or wording beyond feature names. Excel feature *names* (menu labels, function names) are fine.
- Screenshots of Microsoft software are never committed (they live only in `plan/excel/screenshots/`).
- 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).
- **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).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
## 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.
- **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.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
## Running and looking at the app
-`cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- 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`.
## 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.
| <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) |
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: 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) |
@@ -135,6 +135,19 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
| 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`) |
@@ -164,9 +177,11 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Set precision as displayed is applied: with the option on, formula results, spills and numbers typed or reformatted are stored as their number format shows them and formulas calculate from those values; `formulas.calculationOptions` gains `precisionAsDisplayed` (turning it on rounds existing constants; locked constants on a protected sheet keep their value; numbers written to a new sheet are rounded too) (#1343) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
@@ -233,7 +248,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-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
@@ -244,6 +259,18 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 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 |
@@ -262,6 +289,8 @@ 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: Set precision as displayed applied in calculation (#1343) |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
@@ -296,5 +325,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; progressdocs 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 |
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}",
has_doc,
|s,p|insert_number_control(s,p,ControlKind::Spin)
),
cmd!(
"control.format",
"Format Control",
[],
None,
"{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
has_doc,
format
),
cmd!(
"control.assign",
"Assign Action",
[],
None,
"{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
has_doc,
assign
),
cmd!(
"control.setValue",
"Change Control Value",
[],
None,
"{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
has_doc,
set_value
),
cmd!(
"control.press",
"Press Button",
[],
None,
"{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned",
has_doc,
press
),
cmd!(query"control.list","Form Controls",[],None,"{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]",has_doc,list),
"{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)}",
cmd!(noundo"edit.find","Find…",["Home","Editing","Find & Select"],Some("Cmd+F"),"{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)",has_doc,find),
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
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),
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
"{} → {calculation: {mode, iterative, maxIterations, maxChange, precisionAsDisplayed}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
"{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),
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001), precisionAsDisplayed?: bool (Set precision as displayed: numbers are stored as their format shows them; turning it on rounds every number constant permanently)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
"{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening",
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error",
"{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?}",
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), 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?}",
"{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}",
"{name, inputs?, output?: \"F2\" (as macro.run), 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}",
"{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?}",
"{action: start|step|continue|runTo|stop|state, name, inputs?, output?, 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?}",
"{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",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.