Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 260a2f2ebd Merge branch 'main' into fix/sec-xlsx-richdata-index 2026-11-03 17:34:32 -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
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
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
Dylan M. Taylor d30db95e8f xlsx: resolve cell pictures through a per-structure layout and a relationship index
Every rich value rebuilt its structure's whole key list before checking
its value count, and scanned the image relationship table for a
duplicate id. Structures are now laid out once and relationships are
indexed by id (a repeated id stays invalid), so reading cell-picture
metadata is linear in its records.
2026-11-03 11:03:35 +00: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
Dylan M. Taylor 178f3eb745 Protection: authorize the sheet a column move changes, and refuse edits that unlock locked cells (#3196)
- sheet.moveColumns resolved its target sheet from `sheet`, or a
  `Sheet!` range or cell, but its two protection checks were passed
  only the `sheet` parameter, so a qualified range moved columns of
  a protected sheet while the checks authorized the active one. Both
  checks now get the resolved sheet index.
- check_protection compared only cell content, so a sort or duplicate
  removal moving an unlocked cell with equal content onto a locked one
  left the content as it was but made the cell editable. The commit
  check now also refuses a change that leaves a previously locked cell
  unlocked (effective lock, row and column formats included).
2026-11-02 23:45:20 -05:00
Dylan M. Taylor e8ccb5eb15 check: find volatile defined names with a worklist instead of repeated passes (#3187) 2026-11-02 23:33:31 -05:00
112 changed files with 4544 additions and 422 deletions
+31 -10
View File
@@ -31,20 +31,41 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
}
/// Upper-cased defined names whose formula is volatile, directly or through other names.
///
/// Volatility flows backwards along name references: each name is parsed and walked once, and a
/// worklist carries volatility from a name to the names that use it. Rescanning every name until
/// nothing changes was quadratic: a file listing its names so that each pass marked only one more
/// did one full pass per name.
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
let parsed: Vec<(String, Option<Expr>)> =
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
let mut set = HashSet::new();
// Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point.
for _ in 0..=parsed.len() {
let before = set.len();
for (name, e) in &parsed {
if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) {
set.insert(name.clone());
// Upper-cased name -> the names whose formulas refer to it.
let mut users: HashMap<String, Vec<String>> = HashMap::new();
let mut queue = Vec::new();
let none = HashSet::new();
for n in &wb.names {
let name = n.name.to_ascii_uppercase();
let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue };
if is_volatile(&e, &none) {
if set.insert(name.clone()) {
queue.push(name.clone());
}
continue;
}
if set.len() == before {
break;
let mut used = BTreeSet::new();
e.walk(&mut |x| {
if let Expr::Name(r) = x {
used.insert(r.to_ascii_uppercase());
}
});
for r in used {
users.entry(r).or_default().push(name.clone());
}
}
while let Some(v) = queue.pop() {
for user in users.remove(&v).unwrap_or_default() {
if set.insert(user.clone()) {
queue.push(user);
}
}
}
set
+22
View File
@@ -178,6 +178,28 @@ fn volatile_names_propagate() {
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
}
#[test]
fn volatile_names_propagate_through_a_long_chain_in_one_walk() {
// Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan
// until nothing changes marked one name per pass (quadratic). Every name in the chain is
// volatile; a name off the chain is not.
const LEN: usize = 5_000;
let mut wb = book(&[("A1", "=1", n(1.0))]);
let name =
|name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
for i in 0..LEN {
let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) };
wb.names.push(name(format!("Chain{i}"), formula));
}
wb.names.push(name("Plain".into(), "Chain0*0+Other".into()));
wb.names.push(name("Other".into(), "1".into()));
let set = volatile_names(&wb);
assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}"))));
assert!(set.contains("PLAIN"));
assert!(!set.contains("OTHER"));
assert_eq!(set.len(), LEN + 1);
}
#[test]
fn round_trip_of_a_simple_book_is_clean() {
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
+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"])
+4 -3
View File
@@ -359,9 +359,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),
},
+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(),
}
}
+72
View File
@@ -1258,3 +1258,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"));
}
+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);
+7 -3
View File
@@ -459,8 +459,11 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
/// that moves an unlocked cell with equal content onto a locked one changes no content but would
/// leave the cell editable, so the effective lock (row and column formats included) is compared
/// as well.
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
@@ -471,7 +474,8 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
continue;
}
for c in sh.cells.diff(&after.cells) {
if !same_content(sh.cell(c), after.cell(c))
let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
+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) {
+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");
}
+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));
}
}
+33
View File
@@ -1013,3 +1013,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}");
}
}
+73
View File
@@ -695,6 +695,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;
}
}
+4 -4
View File
@@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
// sheet is active: both carry the command's own `sheet` parameter on.
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
// The checks must authorize the sheet this command modifies, not whatever sheet is active:
// both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell).
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
+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('"');
+120 -1
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,
})
}
@@ -291,6 +296,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 +318,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 +359,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.
@@ -389,6 +505,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));
}
+13 -9
View File
@@ -355,15 +355,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 {
+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 {
+151
View File
@@ -5297,6 +5297,42 @@ fn move_columns_checks_the_protection_of_the_named_sheet() {
assert_eq!(v(&s, "A1"), Value::text("A"));
}
#[test]
fn move_columns_checks_the_protection_of_a_sheet_named_by_its_range() {
// A `Sheet!` range or cell picks the sheet the move modifies; the protection checks must
// authorize that sheet too, not the active one.
let mut s = s();
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
for key in ["range", "cell"] {
let err = s.execute("sheet.moveColumns", json!({key: "P!A1", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
assert!(err.to_string().contains("protected"), "{err}");
}
let d = s.doc().unwrap();
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
let pv: Vec<Value> = (0..4).map(|c| sh.value(CellRef::new(0, c))).collect();
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
}
#[test]
fn sorting_cannot_unlock_a_locked_cell_by_moving_equal_content_onto_it() {
// An unlocked 2 sorted onto a locked 2 leaves the content as it was, but would make the cell
// editable; the sort is refused instead.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[2], [2], [1]]})).unwrap();
s.execute("home.lockCell", json!({"range": "A1", "on": false})).unwrap();
s.execute("home.lockCell", json!({"range": "A3", "on": false})).unwrap();
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
let before = s.doc().unwrap().wb.clone();
let err =
s.execute("data.sort", json!({"range": "A1:A3", "header": false, "keys": [{"column": "A", "order": "asc"}]})).expect_err("sort unlocking A2");
assert!(err.to_string().contains("protected sheet"), "{err}");
assert_eq!(*s.doc().unwrap().wb, *before);
assert!(s.execute("cell.set", json!({"cell": "A2", "input": "9"})).is_err(), "A2 stays locked");
}
#[test]
fn a_script_cannot_switch_windows() {
// Stored scripts are scoped to their workbook: a step that activates another window would
@@ -5421,3 +5457,118 @@ 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());
}
+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();
+12
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(),
+1 -12
View File
@@ -425,9 +425,6 @@ impl NumberFormat {
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
/// section, or are returned as is.
/// Longest text a cell holds, in characters (Excel's limit), and so the longest `TEXT` result.
const MAX_CELL_TEXT_CHARS: usize = 32_767;
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
if code.is_empty() {
return Ok(String::new());
@@ -443,15 +440,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
Some(n) => n,
// Every `@` repeats the text: a result past a cell's limit is `#VALUE!`, so nested
// TEXT calls can't multiply it further.
None => {
let out = fmt.format_text(&t).text;
if out.len() > MAX_CELL_TEXT_CHARS && out.chars().count() > MAX_CELL_TEXT_CHARS {
return Err(CellError::Value);
}
return Ok(out);
}
None => return Ok(fmt.format_text(&t).text),
},
Value::Array(_) => return Err(CellError::Value),
};
-4
View File
@@ -396,10 +396,6 @@ fn text_fn() {
assert_eq!(text_function(&Value::Number(1234.5), "$#,##0.00", S), Ok("$1,234.50".into()));
assert_eq!(text_function(&Value::from("5"), "0.00", S), Ok("5.00".into()));
assert_eq!(text_function(&Value::from("abc"), "0.00", S), Ok("abc".into()));
// Each `@` repeats the text: up to a cell's 32,767 characters, then #VALUE!.
let at = "@".repeat(255);
assert_eq!(text_function(&Value::from("x".repeat(128).as_str()), &at, S).map(|s| s.len()), Ok(128 * 255));
assert_eq!(text_function(&Value::from("x".repeat(32_000).as_str()), &at, S), Err(CellError::Value));
assert_eq!(text_function(&Value::Error(CellError::NA), "0", S), Err(CellError::NA));
assert_eq!(text_function(&Value::Number(46302.0), "yyyy", S), Ok("2026".into()));
assert_eq!(text_function(&Value::Number(0.5), "General", S), Ok("0.5".into()));
+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"));
+9 -2
View File
@@ -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;
@@ -556,3 +557,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}"),
+2
View File
@@ -564,6 +564,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
}
+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
+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()?;
+6
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,
+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)]));
+1 -1
View File
@@ -48,7 +48,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),
+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);
+99 -12
View File
@@ -225,10 +225,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 +249,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 +318,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 +349,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) };
@@ -612,6 +625,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 +705,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 +744,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 +776,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 +803,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 +812,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 +960,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 +969,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)
@@ -1038,9 +1107,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));
}
}
+3 -5
View File
@@ -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 { .. } => {}
+16
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() {
+21 -17
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));
}
@@ -333,7 +333,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;
}
@@ -454,12 +456,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 +480,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;
+10
View File
@@ -335,6 +335,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",
+10
View File
@@ -350,6 +350,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",
+10
View File
@@ -337,6 +337,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",
+1
View File
@@ -410,6 +410,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|Save" => "Salvataggio",
"Options|Editing" => "Modifica",
"Show formula bar" => "Mostra barra della formula",
"Highlight the active cell's row" => "Evidenzia la riga della cella attiva",
"Enable AutoComplete for cell values" => "Abilita Completamento automatico per i valori delle celle",
"Save .xlsx workbooks automatically after every change" => "Salva automaticamente le cartelle di lavoro .xlsx dopo ogni modifica",
"Workbook calculation" => "Calcolo della cartella di lavoro",
+10
View File
@@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "일반",
"Options|Save" => "저장",
"Show formula bar" => "수식 입력줄 표시",
"Highlight the active cell's row" => "활성 셀의 행 강조 표시",
"Display options for this workbook" => "이 통합 문서의 표시 옵션",
"Show horizontal scroll bar" => "가로 스크롤 막대 표시",
"Show vertical scroll bar" => "세로 스크롤 막대 표시",
"Show sheet tabs" => "시트 탭 표시",
"Edit Hyperlink…" => "하이퍼링크 편집…",
"Edit Hyperlink" => "하이퍼링크 편집",
"Open Hyperlink" => "하이퍼링크 열기",
"Remove Hyperlink" => "하이퍼링크 제거",
"Resize formula bar" => "수식 입력줄 크기 조정",
"Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용",
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산",
+10
View File
@@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Geral",
"Options|Save" => "Salvar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Highlight the active cell's row" => "Realçar a linha da célula ativa",
"Display options for this workbook" => "Opções de exibição para esta pasta de trabalho",
"Show horizontal scroll bar" => "Mostrar barra de rolagem horizontal",
"Show vertical scroll bar" => "Mostrar barra de rolagem vertical",
"Show sheet tabs" => "Mostrar guias de planilhas",
"Edit Hyperlink…" => "Editar hiperlink…",
"Edit Hyperlink" => "Editar hiperlink",
"Open Hyperlink" => "Abrir hiperlink",
"Remove Hyperlink" => "Remover hiperlink",
"Resize formula bar" => "Redimensionar a barra de fórmulas",
"Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula",
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
"Workbook calculation" => "Cálculo da pasta de trabalho",
+10
View File
@@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Общие",
"Options|Save" => "Сохранение",
"Show formula bar" => "Показывать строку формул",
"Highlight the active cell's row" => "Выделять строку активной ячейки",
"Display options for this workbook" => "Параметры отображения для этой книги",
"Show horizontal scroll bar" => "Показывать горизонтальную полосу прокрутки",
"Show vertical scroll bar" => "Показывать вертикальную полосу прокрутки",
"Show sheet tabs" => "Показывать ярлычки листов",
"Edit Hyperlink…" => "Изменить гиперссылку…",
"Edit Hyperlink" => "Изменить гиперссылку",
"Open Hyperlink" => "Открыть гиперссылку",
"Remove Hyperlink" => "Удалить гиперссылку",
"Resize formula bar" => "Изменить размер строки формул",
"Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек",
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге",
+10
View File
@@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "常规",
"Options|Save" => "保存",
"Show formula bar" => "显示编辑栏",
"Highlight the active cell's row" => "突出显示活动单元格所在行",
"Display options for this workbook" => "此工作簿的显示选项",
"Show horizontal scroll bar" => "显示水平滚动条",
"Show vertical scroll bar" => "显示垂直滚动条",
"Show sheet tabs" => "显示工作表标签",
"Edit Hyperlink…" => "编辑超链接…",
"Edit Hyperlink" => "编辑超链接",
"Open Hyperlink" => "打开超链接",
"Remove Hyperlink" => "删除超链接",
"Resize formula bar" => "调整编辑栏大小",
"Enable AutoComplete for cell values" => "为单元格值启用记忆式键入",
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
"Workbook calculation" => "工作簿计算",
+2
View File
@@ -115,6 +115,8 @@ fn lookup_number_formats(ctx: LookupContext) -> Option<Shortcut> {
Num7 if ctrl && s => borders("outside"),
Minus if ctrl && s => borders("none"),
Num8 if ctrl && s => run("selection.currentRegion"),
// Ctrl+. (Control on a Mac too): Next Corner of Selection.
Period if ctrl && !s => run("selection.nextCorner"),
Semicolon if ctrl && s => Shortcut::Time,
Quote if ctrl && s => Shortcut::ValueAbove,
Quote if ctrl => Shortcut::FormulaAbove,
@@ -48,6 +48,13 @@ pub fn is_group_level(seq: &str) -> bool {
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
}
/// False under a legacy Excel 2003 menu letter that has no ribbon tab (Alt+I, D, V, T): Excel
/// paints no badges for those, so the ribbon stays as it is while the sequence is typed.
pub fn paints_badges(prefix: &str) -> bool {
let Some(root) = prefix.get(..1) else { return true };
SEQUENCES.iter().any(|(seq, entry)| matches!(entry, Entry::Tab(_)) && seq.starts_with(root))
}
fn matches_target(action: &Action, target: &str) -> bool {
match action {
Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target,
@@ -214,6 +221,22 @@ mod tests {
assert!(!has_deeper("X"));
}
#[test]
fn legacy_menu_letters_finish_without_badges() {
assert_eq!(lookup("IR"), Some(Entry::Finish(Action::Command("home.insertRows"))));
assert_eq!(lookup("DS"), Some(Entry::Finish(Action::Dialog("sort"))));
assert_eq!(lookup("EAA"), Some(Entry::Finish(Action::Command("edit.clearAll"))));
for root in ["I", "D", "V", "T"] {
assert!(is_group_level(root), "{root} waits for the menu letter");
assert!(!paints_badges(root), "{root} has no tab, so no badges");
}
assert!(paints_badges(""), "tab badges");
assert!(paints_badges("HO"));
assert!(paints_badges("J"), "the Draw tab's two-letter unit");
assert!(paints_badges("E"), "E and O stay aliases of Home");
assert_eq!(sequences_for("home.insertRows"), vec!["HIR", "IR"], "the ribbon sequence stays first");
}
#[test]
fn group_extension_paints_only_continue_levels() {
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
@@ -372,5 +372,48 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page
("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable
("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print)
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
("IR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert > Rows; Legacy Excel 2003 menu path (#941).
("IC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert > Columns; Legacy Excel 2003 menu path (#941).
("IE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert > Cells; Legacy Excel 2003 menu path (#941).
("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941).
("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941).
("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941).
("IS", Entry::Finish(Action::Control("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941).
("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941).
("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941).
("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941).
("EAA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Edit > Clear > All; Legacy Excel 2003 menu path (#941).
("EAC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Edit > Clear > Contents; Legacy Excel 2003 menu path (#941).
("EAM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Edit > Clear > Comments; Legacy Excel 2003 menu path (#941).
("EF", Entry::Finish(Action::Dialog("find"))), // Excel: Edit > Find; Legacy Excel 2003 menu path (#941).
("EG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Edit > Go To; Legacy Excel 2003 menu path (#941).
("ORA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: Format > Row > AutoFit; Legacy Excel 2003 menu path (#941).
("ORE", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Format > Row > Height; Legacy Excel 2003 menu path (#941).
("ORH", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Format > Row > Hide; Legacy Excel 2003 menu path (#941).
("ORU", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Format > Row > Unhide; Legacy Excel 2003 menu path (#941).
("OCW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Format > Column > Width; Legacy Excel 2003 menu path (#941).
("OCH", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Format > Column > Hide; Legacy Excel 2003 menu path (#941).
("OCU", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Format > Column > Unhide; Legacy Excel 2003 menu path (#941).
("OHR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Format > Sheet > Rename; Legacy Excel 2003 menu path (#941).
("OHH", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Format > Sheet > Hide; Legacy Excel 2003 menu path (#941).
("OHU", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Format > Sheet > Unhide; Legacy Excel 2003 menu path (#941).
("OE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format > Cells; Legacy Excel 2003 menu path (#941).
("OD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Format > Conditional Formatting; Legacy Excel 2003 menu path (#941).
("DFF", Entry::Finish(Action::Command("data.filter"))), // Excel: Data > Filter > AutoFilter; Legacy Excel 2003 menu path (#941).
("DS", Entry::Finish(Action::Dialog("sort"))), // Excel: Data > Sort; Legacy Excel 2003 menu path (#941).
("DGG", Entry::Finish(Action::Command("data.group"))), // Excel: Data > Group and Outline > Group; Legacy Excel 2003 menu path (#941).
("DGU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Data > Group and Outline > Ungroup; Legacy Excel 2003 menu path (#941).
("DGS", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Data > Group and Outline > Show Detail; Legacy Excel 2003 menu path (#941).
("DGH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Data > Group and Outline > Hide Detail; Legacy Excel 2003 menu path (#941).
("DL", Entry::Finish(Action::Command("data.validation"))), // Excel: Data > Validation; Legacy Excel 2003 menu path (#941).
("DT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data > Table (what-if data table); Legacy Excel 2003 menu path (#941).
("VN", Entry::Finish(Action::Command("view.normal"))), // Excel: View > Normal; Legacy Excel 2003 menu path (#941).
("VP", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: View > Page Break Preview; Legacy Excel 2003 menu path (#941).
("VZ", Entry::Finish(Action::Dialog("zoom"))), // Excel: View > Zoom; Legacy Excel 2003 menu path (#941).
("TO", Entry::Finish(Action::Dialog("options"))), // Excel: Tools > Options; Legacy Excel 2003 menu path (#941).
("TUA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Tools > Formula Auditing > Remove All Arrows; Legacy Excel 2003 menu path (#941).
("TUD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Tools > Formula Auditing > Trace Dependents; Legacy Excel 2003 menu path (#941).
("TUT", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Tools > Formula Auditing > Trace Precedents; Legacy Excel 2003 menu path (#941).
("FU", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: File > Page Setup; Legacy Excel 2003 menu path (#941).
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
];
+301
View File
@@ -2582,6 +2582,307 @@
"excel": "Print page",
"note": "containment(Print)"
},
{
"seq": "IR",
"kind": "command",
"target": "home.insertRows",
"excel": "Insert > Rows",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IC",
"kind": "command",
"target": "home.insertColumns",
"excel": "Insert > Columns",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IE",
"kind": "dialog",
"target": "insertCells",
"excel": "Insert > Cells",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IW",
"kind": "command",
"target": "home.insertSheet",
"excel": "Insert > Worksheet",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IF",
"kind": "dialog",
"target": "insertFunction",
"excel": "Insert > Function",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IND",
"kind": "dialog",
"target": "defineName",
"excel": "Insert > Name > Define",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IS",
"kind": "control",
"target": "insert.symbol",
"excel": "Insert > Symbol",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ED",
"kind": "dialog",
"target": "deleteCells",
"excel": "Edit > Delete",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EM",
"kind": "dialog",
"target": "moveSheet",
"excel": "Edit > Move or Copy Sheet",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EL",
"kind": "command",
"target": "home.deleteSheet",
"excel": "Edit > Delete Sheet",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EAA",
"kind": "command",
"target": "edit.clearAll",
"excel": "Edit > Clear > All",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EAC",
"kind": "command",
"target": "edit.clearContents",
"excel": "Edit > Clear > Contents",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EAM",
"kind": "command",
"target": "edit.clearComments",
"excel": "Edit > Clear > Comments",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EF",
"kind": "dialog",
"target": "find",
"excel": "Edit > Find",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EG",
"kind": "dialog",
"target": "goTo",
"excel": "Edit > Go To",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORA",
"kind": "command",
"target": "home.autofitRowHeight",
"excel": "Format > Row > AutoFit",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORE",
"kind": "dialog",
"target": "rowHeight",
"excel": "Format > Row > Height",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORH",
"kind": "command",
"target": "home.hideRows",
"excel": "Format > Row > Hide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORU",
"kind": "command",
"target": "home.unhideRows",
"excel": "Format > Row > Unhide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OCW",
"kind": "dialog",
"target": "columnWidth",
"excel": "Format > Column > Width",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OCH",
"kind": "command",
"target": "home.hideColumns",
"excel": "Format > Column > Hide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OCU",
"kind": "command",
"target": "home.unhideColumns",
"excel": "Format > Column > Unhide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OHR",
"kind": "dialog",
"target": "renameSheet",
"excel": "Format > Sheet > Rename",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OHH",
"kind": "command",
"target": "sheet.hide",
"excel": "Format > Sheet > Hide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OHU",
"kind": "command",
"target": "sheet.unhide",
"excel": "Format > Sheet > Unhide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OE",
"kind": "command",
"target": "home.formatCells",
"excel": "Format > Cells",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OD",
"kind": "command",
"target": "home.conditionalFormat",
"excel": "Format > Conditional Formatting",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DFF",
"kind": "command",
"target": "data.filter",
"excel": "Data > Filter > AutoFilter",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DS",
"kind": "dialog",
"target": "sort",
"excel": "Data > Sort",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGG",
"kind": "command",
"target": "data.group",
"excel": "Data > Group and Outline > Group",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGU",
"kind": "command",
"target": "data.ungroup",
"excel": "Data > Group and Outline > Ungroup",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGS",
"kind": "command",
"target": "data.showDetail",
"excel": "Data > Group and Outline > Show Detail",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGH",
"kind": "command",
"target": "data.hideDetail",
"excel": "Data > Group and Outline > Hide Detail",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DL",
"kind": "command",
"target": "data.validation",
"excel": "Data > Validation",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DT",
"kind": "command",
"target": "data.dataTable",
"excel": "Data > Table (what-if data table)",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "VN",
"kind": "command",
"target": "view.normal",
"excel": "View > Normal",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "VP",
"kind": "command",
"target": "view.pageBreakPreview",
"excel": "View > Page Break Preview",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "VZ",
"kind": "dialog",
"target": "zoom",
"excel": "View > Zoom",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TO",
"kind": "dialog",
"target": "options",
"excel": "Tools > Options",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TUA",
"kind": "command",
"target": "formulas.removeArrows",
"excel": "Tools > Formula Auditing > Remove All Arrows",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TUD",
"kind": "command",
"target": "formulas.traceDependents",
"excel": "Tools > Formula Auditing > Trace Dependents",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TUT",
"kind": "command",
"target": "formulas.tracePrecedents",
"excel": "Tools > Formula Auditing > Trace Precedents",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "FU",
"kind": "dialog",
"target": "pageSetup",
"excel": "File > Page Setup",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "Q",
"kind": "command",
+13
View File
@@ -88,12 +88,18 @@ pub struct UiState {
pub ribbon_tab: String,
pub ribbon_collapsed: bool,
pub formula_bar_expanded: bool,
/// Lines the expanded formula bar shows: the height its bottom edge was last dragged to.
/// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height.
pub formula_bar_lines: usize,
pub status_bar: bool,
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
pub animations: bool,
/// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed
/// from the one matching entry in the same column.
pub autocomplete: bool,
/// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so
/// the eye keeps its place across a wide table. A preference: nothing goes into the file.
pub highlight_active_row: bool,
/// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every
/// change. A preference like the rest of this struct — it stays on across restarts (#1901).
pub autosave: bool,
@@ -102,6 +108,10 @@ pub struct UiState {
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
#[serde(skip)]
pub keytip_invoke: Option<&'static str>,
/// A keytip control request for the next frame: its tab was switched to this frame, so its
/// control is drawn only then.
#[serde(skip)]
pub keytip_invoke_next: Option<&'static str>,
/// Interface language. English by default (so tests and headless renders never depend on the
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
@@ -137,12 +147,15 @@ impl Default for UiState {
ribbon_tab: "Home".into(),
ribbon_collapsed: false,
formula_bar_expanded: false,
formula_bar_lines: formula_bar::MAX_LINES,
status_bar: true,
animations: true,
autocomplete: true,
highlight_active_row: false,
autosave: false,
recent: vec![],
keytip_invoke: None,
keytip_invoke_next: None,
language: i18n::Language::En,
interface_scale: 100,
}
+10 -4
View File
@@ -459,10 +459,7 @@ pub fn parse_refs(text: &str) -> Vec<(Option<String>, RangeRef)> {
text.split(',')
.filter_map(|part| {
let part = part.trim();
let (sheet, body) = match part.rsplit_once('!') {
Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b),
None => (None, part),
};
let (sheet, body) = gridcraft_engine::core::split_sheet(part);
RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r))
})
.take(500)
@@ -2399,6 +2396,15 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e
mod tests {
use super::*;
#[test]
fn example_references_unquote_sheet_names_as_formulas_do() {
// #2012: one shared reading of `'Bob''s'!A1` and `'a!b'!A1`.
let refs = parse_refs("'Bob''s'!$A$1:$B$2, 'a!b'!C3, D4");
let sheets: Vec<Option<String>> = refs.iter().map(|(s, _)| s.clone()).collect();
assert_eq!(sheets, [Some("Bob's".to_string()), Some("a!b".to_string()), None]);
assert_eq!(refs.iter().map(|(_, r)| r.a1()).collect::<Vec<_>>(), ["A1:B2", "C3", "D4"]);
}
#[test]
fn an_edited_input_keeps_a_shape_a_macro_may_declare() {
let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() };
+17 -1
View File
@@ -162,10 +162,26 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec<gr
}
painted += 1;
let pc = p.with_clip_rect(clip);
grid::paint_quadrant(grid::QuadrantPaint { painter: &pc, geo, workbook: wb, sheet_index: si, sheet: sh, rows, cols, cf, tokens: t });
let active_row = app.ui.highlight_active_row.then_some(sel.active.row);
grid::paint_quadrant(grid::QuadrantPaint {
painter: &pc,
geo,
workbook: wb,
sheet_index: si,
sheet: sh,
rows,
cols,
cf,
tokens: t,
active_row,
});
if !pg.is_data_page(i, j) {
// "Click to add data" pages: faded until something is typed there.
pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75));
// The fade would all but hide the active row's band: it goes on again.
if let Some(band) = active_row.and_then(|row| grid::active_row_band(geo, sh, row, rows, cols)) {
pc.rect_filled(band, 0.0, t.active_row);
}
let size = (14.0 * geo.z / DISPLAY_SCALE).clamp(8.0, 22.0);
pc.text(clip.center(), Align2::CENTER_CENTER, tr(app, "Click to add data"), theme::ui_font(size), Color32::from_gray(150));
}
+9 -1
View File
@@ -27,7 +27,15 @@ pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
let Some(rest) = app
.keytips
.prefix()
.filter(|prefix| keytip_table::paints_badges(prefix))
.and_then(|prefix| sequence.strip_prefix(prefix))
.filter(|s| !s.is_empty())
else {
return;
};
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
+3 -1
View File
@@ -213,7 +213,9 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
});
let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true);
control_keytip(app, ui, &sym, "insert.symbol");
egui::Popup::menu(&sym).show(|ui| {
// Opened by its key tip too (Alt+I, S): the gallery picks the symbol, nothing is inserted before.
let forced = invoke(app, "insert.symbol");
egui::Popup::menu(&sym).open_memory(popup_command(forced, sym.clicked())).show(|ui| {
ui.set_width(260.0);
ui.horizontal_wrapped(|ui| {
for ch in [
+3
View File
@@ -32,6 +32,9 @@ pub struct Services {
/// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes
/// GridCraft's own clipboard.
pub paste_text: Option<Box<dyn FnMut() -> Option<String>>>,
/// Read the picture on the system clipboard for the ribbon and menu Paste commands when it
/// holds no text (Cmd/Ctrl+V gets it from egui's `Event::PasteImage`).
pub paste_image: Option<Box<dyn FnMut() -> Option<image::RgbaImage>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Download>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
+176 -152
View File
@@ -100,29 +100,40 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32
/// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar.
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Only the controls drawn this frame have a rectangle: a hidden one keeps no stale geometry.
app.grid.hscroll = None;
app.grid.tab_split = None;
// File › Options › Advanced › Display options for this workbook (#3101): with neither the
// sheet tabs nor the horizontal scroll bar, the bar isn't there at all.
let view = app.session.active().map(|d| d.wb.view).unwrap_or_default();
if !view.show_sheet_tabs && !view.show_horizontal_scroll {
return;
}
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
let rect = ui.max_rect();
ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border));
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(8.0);
if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() {
app.run_or_alert("sheet.previous", json!({}));
if view.show_sheet_tabs {
if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() {
app.run_or_alert("sheet.previous", json!({}));
}
if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() {
app.run_or_alert("sheet.next", json!({}));
}
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default();
let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default();
let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect();
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
&& let Ok(i) = i.parse::<usize>()
{
activate_sheet(app, i);
}
ui.add_space(8.0);
}
if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() {
app.run_or_alert("sheet.next", json!({}));
}
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default();
let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default();
let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect();
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
&& let Ok(i) = i.parse::<usize>()
{
activate_sheet(app, i);
}
ui.add_space(8.0);
let Some(d) = app.session.active() else { return };
let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width());
let uid = d.uid;
@@ -143,6 +154,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
// After the handle at the scrollbar's left edge is dragged, the tabs and New sheet
// take the share it was dragged to.
let tabs_width = match share {
// Without the scroll bar the tabs have the whole bar.
_ if !view.show_horizontal_scroll => (bar_width - NEW_SHEET_WIDTH).max(0.0),
Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0),
None => (bar_width * 0.6).max(100.0),
};
@@ -155,154 +168,159 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
data.insert_temp(reveal_id, reveal_key);
changed
});
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
// Translate vertical wheel movement into horizontal
// worksheet-tab scrolling.
let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) {
ui.input(|i| {
let d = i.smooth_scroll_delta;
if d.x.abs() > d.y.abs() { d.x } else { d.y }
})
} else {
0.0
};
if view.show_sheet_tabs {
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
// Translate vertical wheel movement into horizontal
// worksheet-tab scrolling.
let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) {
ui.input(|i| {
let d = i.smooth_scroll_delta;
if d.x.abs() > d.y.abs() { d.x } else { d.y }
})
} else {
0.0
};
egui::ScrollArea::horizontal()
.id_salt("worksheet_tabs_horizontal")
.auto_shrink([true, false])
.min_scrolled_width(0.0)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
if wheel_scroll.abs() > 0.0 {
ui.scroll_with_delta(vec2(wheel_scroll, 0.0));
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (i, name, color, active) in sheets {
let renaming = app.grid.renaming_tab == Some(i);
let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
let w = (tw + 32.0).max(72.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
if active && reveal_active {
resp.scroll_to_me(None);
}
let r = r.shrink2(vec2(0.0, 1.0));
if active {
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
ui.painter().rect_stroke(
Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max),
4.0,
Stroke::new(1.0, t.ribbon_border),
StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
if let Some(c) = color {
let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0));
ui.painter().rect_filled(band, 1.0, theme::color32(c));
} else if active {
let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5));
ui.painter().rect_filled(band, 1.0, t.accent);
}
if renaming {
let mut text = app.grid.rename_text.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
let ed = child
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
.accessible_name(app.ui.language.tr("Sheet Name"));
app.grid.rename_text = text.clone();
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
ed.request_focus();
egui::ScrollArea::horizontal()
.id_salt("worksheet_tabs_horizontal")
.auto_shrink([true, false])
.min_scrolled_width(0.0)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
if wheel_scroll.abs() > 0.0 {
ui.scroll_with_delta(vec2(wheel_scroll, 0.0));
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (i, name, color, active) in sheets {
let renaming = app.grid.renaming_tab == Some(i);
let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
let w = (tw + 32.0).max(72.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
if active && reveal_active {
resp.scroll_to_me(None);
}
if ed.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name {
app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text}));
let r = r.shrink2(vec2(0.0, 1.0));
if active {
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
ui.painter().rect_stroke(
Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max),
4.0,
Stroke::new(1.0, t.ribbon_border),
StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
if let Some(c) = color {
let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0));
ui.painter().rect_filled(band, 1.0, theme::color32(c));
} else if active {
let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5));
ui.painter().rect_filled(band, 1.0, t.accent);
}
if renaming {
let mut text = app.grid.rename_text.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
let ed = child
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
.accessible_name(app.ui.language.tr("Sheet Name"));
app.grid.rename_text = text.clone();
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
ed.request_focus();
}
app.grid.renaming_tab = None;
if ed.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name {
app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text}));
}
app.grid.renaming_tab = None;
}
} else {
ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text });
}
} else {
ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text });
}
// A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect
// Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active.
if resp.clicked() || (resp.secondary_clicked() && !active) {
activate_sheet(app, i);
}
if resp.double_clicked() {
app.grid.renaming_tab = Some(i);
app.grid.rename_text = name.clone();
}
// Drag to reorder; with the copy modifier held, drop a copy instead.
let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac);
if resp.dragged() && copy {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
}
if resp.drag_stopped()
&& let Some(p) = ui.input(|inp| inp.pointer.interact_pos())
{
let dx = p.x - r.center().x;
let steps = (dx / w).round() as i64;
if let Some(params) = tab_drop(i, steps, copy) {
app.run_or_alert("sheet.move", params);
// A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect
// Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active.
if resp.clicked() || (resp.secondary_clicked() && !active) {
activate_sheet(app, i);
}
}
resp.context_menu(|ui| {
ui.set_min_width(180.0);
if ui.button(app.ui.language.tr("Insert Sheet")).clicked() {
app.run_or_alert("home.insertSheet", json!({"before": i}));
}
if ui.button(app.ui.language.tr("Delete")).clicked() {
app.run_or_alert("home.deleteSheet", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Rename")).clicked() {
if resp.double_clicked() {
app.grid.renaming_tab = Some(i);
app.grid.rename_text = name.clone();
}
if ui.button(app.ui.language.tr("Move or Copy…")).clicked() {
app.open_dialog("moveSheet", json!({"sheet": i}));
// Drag to reorder; with the copy modifier held, drop a copy instead.
let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac);
if resp.dragged() && copy {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
}
if ui.button(app.ui.language.tr("Duplicate")).clicked() {
app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true}));
if resp.drag_stopped()
&& let Some(p) = ui.input(|inp| inp.pointer.interact_pos())
{
let dx = p.x - r.center().x;
let steps = (dx / w).round() as i64;
if let Some(params) = tab_drop(i, steps, copy) {
app.run_or_alert("sheet.move", params);
}
}
ui.menu_button(app.ui.language.tr("Tab Color"), |ui| {
let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default();
if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) {
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
resp.context_menu(|ui| {
ui.set_min_width(180.0);
if ui.button(app.ui.language.tr("Insert Sheet")).clicked() {
app.run_or_alert("home.insertSheet", json!({"before": i}));
}
if ui.button(app.ui.language.tr("Delete")).clicked() {
app.run_or_alert("home.deleteSheet", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Rename")).clicked() {
app.grid.renaming_tab = Some(i);
app.grid.rename_text = name.clone();
}
if ui.button(app.ui.language.tr("Move or Copy…")).clicked() {
app.open_dialog("moveSheet", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Duplicate")).clicked() {
app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true}));
}
ui.menu_button(app.ui.language.tr("Tab Color"), |ui| {
let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default();
if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) {
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
ui.close();
}
});
ui.separator();
if ui.button(app.ui.language.tr("Hide")).clicked() {
app.run_or_alert("sheet.hide", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Unhide…")).clicked() {
app.run_or_alert("sheet.unhide", json!({}));
}
ui.separator();
let protected = app.active_sheet_protected();
if ui
.button(if protected {
app.ui.language.tr("Unprotect Sheet…")
} else {
app.ui.language.tr("Protect Sheet…")
})
.clicked()
{
app.open_dialog("protectSheet", json!({}));
}
if ui.button(app.ui.language.tr("Select All Sheets")).clicked() {
ui.close();
}
});
ui.separator();
if ui.button(app.ui.language.tr("Hide")).clicked() {
app.run_or_alert("sheet.hide", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Unhide…")).clicked() {
app.run_or_alert("sheet.unhide", json!({}));
}
ui.separator();
let protected = app.active_sheet_protected();
if ui
.button(if protected {
app.ui.language.tr("Unprotect Sheet…")
} else {
app.ui.language.tr("Protect Sheet…")
})
.clicked()
{
app.open_dialog("protectSheet", json!({}));
}
if ui.button(app.ui.language.tr("Select All Sheets")).clicked() {
ui.close();
}
});
}
}
});
});
});
});
ui.add_space(6.0);
if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() {
app.run_or_alert("home.insertSheet", json!({}));
});
ui.add_space(6.0);
if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() {
app.run_or_alert("home.insertSheet", json!({}));
}
}
if !view.show_horizontal_scroll {
return;
}
// The track ends where the grid does, so it stops under the vertical scrollbar.
let avail = ui.available_rect_before_wrap();
@@ -312,8 +330,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
let split_left =
share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left()));
let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom()));
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
// Without sheet tabs there is nothing to split: the scroll bar takes the bar.
let track_left = if view.show_sheet_tabs {
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
split.right()
} else {
avail.left()
};
let track = Rect::from_min_max(pos2(track_left, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
crate::grid::horizontal_scrollbar(app, ui, track, &t);
});
});
+3
View File
@@ -63,6 +63,8 @@ pub struct Tokens {
pub header_sel_text: Color32,
pub header_all_bg: Color32,
pub sel_fill: Color32,
/// The tint over the active cell's row (File ▸ Options ▸ General).
pub active_row: Color32,
pub sel_border: Color32,
pub input_bg: Color32,
pub input_border: Color32,
@@ -217,6 +219,7 @@ impl Tokens {
header_sel_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)),
header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), Color32::from_rgb(0x2E, 0x55, 0x3E)),
sel_fill: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C), Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24)),
active_row: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x2C), Color32::from_rgba_unmultiplied(0x6F, 0xD3, 0x9A, 0x30)),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)),
input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)),
+18 -1
View File
@@ -31,6 +31,8 @@ pub struct WindowUi {
ribbon_tab: String,
/// The status bar's Ready/Enter/Edit, which follows the window's cell draft.
mode: gridcraft_engine::Mode,
/// A key tip's control request waiting for the frame that draws this window's ribbon.
keytip_invoke_next: Option<&'static str>,
}
impl WindowUi {
@@ -70,6 +72,7 @@ impl SheetApp {
open_switch_windows,
ribbon_tab,
mode,
keytip_invoke_next,
} = w;
std::mem::swap(&mut self.editor, editor);
std::mem::swap(&mut self.text_box_editor, text_box_editor);
@@ -86,6 +89,7 @@ impl SheetApp {
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab);
std::mem::swap(&mut self.session.mode, mode);
std::mem::swap(&mut self.ui.keytip_invoke_next, keytip_invoke_next);
}
/// The window whose interface state is in the per-window fields.
@@ -98,6 +102,14 @@ impl SheetApp {
/// in (a window never seen before starts fresh). A closed window's state is dropped.
pub fn follow_active_window(&mut self) {
let active = self.session.active_window();
// A window hidden while parked (from another window's View › Hide) isn't drawn either:
// its waiting key tip request goes, as it does for a window hidden as it is left.
let session = &self.session;
for (id, parked) in &mut self.parked {
if session.window(*id).is_some_and(|w| w.hidden) {
parked.keytip_invoke_next = None;
}
}
if active == self.ui_window {
return;
}
@@ -108,7 +120,12 @@ impl SheetApp {
};
let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab);
self.swap_window_ui(&mut outgoing);
if self.session.window(previous).is_some() {
if let Some(window) = self.session.window(previous) {
// A hidden window isn't drawn: a key tip request waiting for its next frame would
// fire whenever it is shown again, so it goes.
if window.hidden {
outgoing.keytip_invoke_next = None;
}
self.parked.insert(previous, outgoing);
}
if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) {
@@ -0,0 +1,150 @@
//! "Highlight the active cell's row": a global preference, off by default, that tints the whole
//! row of the active cell and leaves the workbook alone.
use egui::{Shape, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::theme::Tokens;
use gridcraft_ui_egui::{SheetApp, UiState, grid::Geo, grid::active_row_band};
use serde_json::json;
fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> {
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(4);
h
}
fn book(active: &str) -> Session {
let mut session = Session::new();
session.new_workbook();
session.execute("selection.set", json!({"range": active})).unwrap();
session
}
/// The rectangles painted with the highlight's own colour.
fn tinted(h: &egui_kittest::Harness<'_, SheetApp>) -> Vec<egui::Rect> {
let tint = Tokens::get(&h.ctx).active_row;
h.output()
.shapes
.iter()
.filter_map(|clipped| match &clipped.shape {
Shape::Rect(r) if r.fill == tint => Some(r.rect),
_ => None,
})
.collect()
}
#[test]
fn it_is_off_by_default_and_paints_nothing() {
assert!(!UiState::default().highlight_active_row);
let h = harness(book("C5"));
assert!(tinted(&h).is_empty());
}
#[test]
fn turned_on_it_tints_the_active_row_across_the_columns() {
let mut h = harness(book("C5"));
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({"on": true})), Ok(json!({"on": true})));
h.run_steps(2);
let bands = tinted(&h);
assert_eq!(bands.len(), 1, "{bands:?}");
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let cell = geo.cell_rect(sheet, CellRef::parse("C5").unwrap());
assert_eq!((bands[0].top(), bands[0].bottom()), (cell.top(), cell.bottom()));
assert!(bands[0].left() <= geo.cells.left() + 1.0, "starts at the first column: {:?}", bands[0]);
assert!(bands[0].right() >= geo.cells.right() - 1.0, "reaches the last visible column: {:?}", bands[0]);
}
#[test]
fn the_band_follows_the_active_cell_and_only_the_row() {
let mut h = harness(book("C5"));
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.state_mut().session.execute("selection.set", json!({"range": "E9"})).unwrap();
h.run_steps(2);
let bands = tinted(&h);
assert_eq!(bands.len(), 1, "a row, and no column band: {bands:?}");
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
assert_eq!(bands[0].top(), geo.cell_rect(sheet, CellRef::parse("E9").unwrap()).top());
}
#[test]
fn it_applies_to_every_sheet_and_workbook() {
let mut h = harness(book("C5"));
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
h.run_steps(2);
assert_eq!(tinted(&h).len(), 1, "another sheet");
h.state_mut().session.new_workbook();
h.run_steps(2);
assert_eq!(tinted(&h).len(), 1, "another workbook");
}
#[test]
fn turning_it_off_clears_the_band_and_the_toggle_leaves_the_workbook_alone() {
let mut h = harness(book("C5"));
let dirty = h.state().session.active().unwrap().is_dirty();
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": true})), "no parameters: it toggles");
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty);
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": false})));
h.run_steps(2);
assert!(tinted(&h).is_empty());
assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty);
}
#[test]
fn the_preference_survives_a_restart_and_older_settings_read_as_off() {
let state = UiState { highlight_active_row: true, ..UiState::default() };
let saved = serde_json::to_string(&state).unwrap();
assert!(serde_json::from_str::<UiState>(&saved).unwrap().highlight_active_row);
assert!(!serde_json::from_str::<UiState>("{}").unwrap().highlight_active_row);
}
#[test]
fn a_hidden_row_or_a_row_outside_the_quadrant_has_no_band() {
let mut session = book("A1");
session.execute("home.rowHeight", json!({"rows": "5:5", "height": 0})).unwrap();
let h = harness(session);
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
assert_eq!(active_row_band(&geo, sheet, 4, (0, 20), (0, 5)), None, "hidden");
assert_eq!(active_row_band(&geo, sheet, 30, (0, 20), (0, 5)), None, "outside");
assert!(active_row_band(&geo, sheet, 6, (0, 20), (0, 5)).is_some());
}
#[test]
fn in_page_layout_the_band_shows_on_a_click_to_add_data_page_too() {
let mut h = harness(book("C5"));
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.state_mut().session.execute("view.pageLayout", json!({})).unwrap();
h.run_steps(3);
let bands = tinted(&h);
// Each page paints its own part of the row; the faded page paints it again over the fade.
let repeated = bands.iter().filter(|b| bands.iter().filter(|o| o == b).count() == 2).count();
assert_eq!(repeated, 2, "{bands:?}");
assert!(bands.iter().all(|b| b.top() == bands[0].top()), "one row: {bands:?}");
}
#[test]
fn with_frozen_panes_the_band_crosses_the_frozen_columns() {
let mut h = harness(book("C5"));
h.state_mut().session.execute("selection.set", json!({"range": "C3"})).unwrap();
h.state_mut().session.execute("view.freezePanes", json!({})).unwrap();
h.state_mut().session.execute("selection.set", json!({"range": "E7"})).unwrap();
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.run_steps(2);
let bands = tinted(&h);
assert_eq!(bands.len(), 2, "the frozen columns' pane and the scrolling one: {bands:?}");
assert_eq!(bands[0].top(), bands[1].top());
}
+37
View File
@@ -160,3 +160,40 @@ fn merged_range_draws_the_outline_from_its_edge_cells() {
assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge");
assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area");
}
/// #2544: a merged range in frozen columns keeps its whole outline while the others scroll: the
/// bottom edge under its last column ends at that column's own right edge, the freeze line.
#[test]
fn frozen_merged_range_keeps_its_outline_when_scrolled() {
let mut h = harness();
h.state_mut().run("home.borders", json!({"range": "A2:B3", "preset": "thickOutside", "color": "#C81E1E"})).unwrap();
h.state_mut().run("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
h.state_mut().run("view.freezePanes", json!({"cell": "C1"})).unwrap();
h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap();
h.state_mut().view_mut().unwrap().scroll.x = 300.0;
h.run_steps(6);
let freeze_x = {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
geo.cells.left() + geo.frozen_w
};
let red = Color32::from_rgb(0xC8, 0x1E, 0x1E);
let right_ends: Vec<f32> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::LineSegment { points, stroke } if stroke.color == red && (points[0].y - points[1].y).abs() < 0.5 => {
Some(points[0].x.max(points[1].x))
}
_ => None,
})
.collect();
assert_eq!(right_ends.len(), 4, "top and bottom edges, one segment per column: {right_ends:?}");
assert_eq!(
right_ends.iter().filter(|x| (**x - freeze_x).abs() < 1.0).count(),
2,
"B's segments end at the freeze line: {right_ends:?} vs {freeze_x}"
);
}
+77
View File
@@ -482,3 +482,80 @@ fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() {
h.run_steps(2);
assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes");
}
/// The floating pictures on the active sheet: (anchor cell, width, height, mime).
fn pictures(h: &Harness) -> Vec<(String, f32, f32, String)> {
let sh = h.state().session.active().unwrap().wb.active().unwrap();
sh.images.iter().map(|i| (i.anchor.cell.a1(), i.anchor.width, i.anchor.height, i.mime.clone())).collect()
}
#[test]
fn cmd_v_with_a_picture_on_the_clipboard_inserts_it_at_the_active_cell() {
// A screenshot or a browser image: the clipboard has pixels and no text (#699, #3018, #3182).
let mut h = harness(Services::default());
h.state_mut().run("selection.set", json!({"cell": "C5"})).unwrap();
let image = egui::ColorImage::new([4, 3], vec![egui::Color32::from_rgb(200, 30, 30); 12]);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(2);
assert!(h.state().message.is_none(), "{:?}", h.state().message);
assert_eq!(pictures(&h), vec![("C5".to_string(), 4.0, 3.0, "image/png".to_string())]);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
let png = image::load_from_memory(&sh.images[0].data).unwrap().to_rgba8();
assert_eq!(png.get_pixel(3, 2).0, [200, 30, 30, 255], "the pixels survive the PNG round trip");
// One undo step removes it.
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(pictures(&h).is_empty());
}
#[test]
fn a_malformed_clipboard_picture_is_refused_without_inserting() {
let mut h = harness(Services::default());
let mut image = egui::ColorImage::new([4, 3], vec![egui::Color32::WHITE; 12]);
image.pixels.truncate(5);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(2);
assert!(pictures(&h).is_empty());
assert!(h.state().message.is_some(), "the user is told the paste failed");
dismiss(&mut h);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new()))));
h.run_steps(2);
assert!(pictures(&h).is_empty());
// Wider than the 16384-pixel limit: refused before its pixels are copied.
dismiss(&mut h);
let wide = egui::ColorImage::new([20_000, 1], vec![egui::Color32::WHITE; 20_000]);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(wide)));
h.run_steps(2);
assert!(pictures(&h).is_empty());
let message = format!("{:?}", h.state().message);
assert!(message.contains("too large"), "{message}");
}
#[test]
fn ribbon_paste_pastes_a_clipboard_picture_when_there_is_no_text() {
let reads = Rc::new(RefCell::new(0));
let count = reads.clone();
let (_, mut services) = system_clipboard("");
services.paste_image = Some(Box::new(move || {
*count.borrow_mut() += 1;
Some(image::RgbaImage::from_pixel(5, 2, image::Rgba([0, 0, 255, 255])))
}));
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "B7"})).unwrap();
click_paste(&mut h);
assert_eq!(*reads.borrow(), 1);
assert_eq!(pictures(&h), vec![("B7".to_string(), 5.0, 2.0, "image/png".to_string())]);
}
#[test]
fn ribbon_paste_prefers_clipboard_text_over_a_picture() {
let (_, mut services) = system_clipboard("words");
services.paste_image = Some(Box::new(|| Some(image::RgbaImage::new(1, 1))));
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "D2"})).unwrap();
click_paste(&mut h);
assert_eq!(value_at(&h, "D2"), Some(Value::from("words")));
assert!(pictures(&h).is_empty());
}
@@ -100,3 +100,36 @@ fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() {
h.run_steps(4);
assert!(h.state().grid.context_menu.is_some(), "the menu stays open");
}
#[test]
fn a_linked_cell_offers_to_edit_open_and_remove_its_link() {
// #2120: the menu only offered "Link…", and its dialog was titled Insert Hyperlink.
use egui_kittest::kittest::Queryable as _;
let mut h = harness();
h.state_mut().session.execute("insert.link", serde_json::json!({"cell": "B2", "target": "#Sheet1!D9", "text": "Go"})).unwrap();
h.run_steps(2);
quick_right_click(&mut h, "B2");
assert!(h.query_by_label("Edit Hyperlink…").is_some() && h.query_by_label("Open Hyperlink").is_some(), "linked cell menu");
assert!(h.query_by_label("Link…").is_none());
// Open Hyperlink follows it (an internal reference selects its cell).
h.get_by_label("Open Hyperlink").click();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("D9").unwrap());
// Edit Hyperlink opens the link's own dialog.
quick_right_click(&mut h, "B2");
h.get_by_label("Edit Hyperlink…").click();
h.run_steps(4);
let d = h.state().dialog.as_ref().expect("the link dialog");
assert_eq!(d.title, "Edit Hyperlink");
assert_eq!(d.values.get("target").and_then(|v| v.as_str()), Some("#Sheet1!D9"));
h.state_mut().dialog = None;
h.run_steps(2);
// Remove Hyperlink takes the link away; a cell without one offers Link… again.
quick_right_click(&mut h, "B2");
h.get_by_label("Remove Hyperlink").click();
h.run_steps(4);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert!(!sh.hyperlinks.contains_key(&CellRef::parse("B2").unwrap()));
quick_right_click(&mut h, "B2");
assert!(h.query_by_label("Link…").is_some() && h.query_by_label("Edit Hyperlink…").is_none());
}
+27
View File
@@ -172,6 +172,33 @@ fn moving_a_selection_keeps_move_and_still_moves_cells() {
assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0));
}
#[test]
fn moving_a_selection_onto_data_asks_before_replacing_it() {
// #3136: the drop replaced the cells there without asking.
let mut h = harness();
h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap();
h.run_steps(2);
let r = selection_rect(&h);
press(&mut h, pos2(r.left() + 10.0, r.top() + 1.0));
let target = cell(&h, "A1").center();
move_with_cursor(&mut h, target, CursorIcon::Move);
button(&mut h, target, false);
h.run_steps(1);
let q = h.state().macros.question.clone().expect("a question before replacing A1:B1");
assert_eq!(q.text, "There's already data here. Do you want to replace it?");
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::text("A"));
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Number(1.0));
// OK replaces them.
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert!(h.state().macros.question.is_none());
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1.0));
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(2.0));
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty);
}
#[test]
fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() {
for cols in [true, false] {
@@ -34,3 +34,27 @@ fn typing_right_after_ctrl_f_fills_find_what() {
fn typing_right_after_ctrl_h_fills_find_what() {
assert_eq!(type_after_shortcut(Key::H), Some(json!("abc")));
}
/// With a range selected, Find All in the dialog lists only the matches inside it, as in Excel
/// (#3224); with one cell selected it searches the whole sheet.
#[test]
fn find_all_with_a_range_selected_searches_only_the_range() {
use egui_kittest::kittest::Queryable;
for (range, expected) in [("B1:B3", 2), ("C5", 4)] {
let mut h = harness();
let app = h.state_mut();
app.session.execute("range.setValues", json!({"range": "A1:B3", "values": [["pear", "pear"], ["fig", "fig"], ["pear", "pear"]]})).unwrap();
app.session.execute("selection.set", json!({"range": range})).unwrap();
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: CTRL });
h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: CTRL });
h.run_steps(6);
h.input_mut().events.push(Event::Text("pear".into()));
h.run_steps(4);
h.get_by_label("Find All").click();
h.run_steps(4);
let d = h.state().dialog.as_ref().expect("find dialog is open");
let count = d.result.as_ref().map(|r| r["count"].clone());
assert_eq!(count, Some(json!(expected)), "{range} selected");
}
}
+78 -1
View File
@@ -1,6 +1,6 @@
//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar.
use egui::{Event, Key, Modifiers};
use egui::{Event, Key, Modifiers, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
@@ -84,3 +84,80 @@ fn expand_formula_bar_command_sets_or_toggles_the_state() {
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false})));
assert!(!expanded(&h));
}
fn drag(h: &mut Harness, from: egui::Pos2, to: egui::Pos2) {
use egui::PointerButton;
h.input_mut().events.extend([
Event::PointerMoved(from),
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
]);
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(20);
}
fn bar(h: &Harness) -> gridcraft_ui_egui::formula_bar::BarLayout {
gridcraft_ui_egui::formula_bar::BarLayout::get(&h.ctx)
}
#[test]
fn dragging_the_bottom_edge_sets_the_height_that_expanding_returns_to() {
let mut h = harness();
h.run_steps(20);
let start = bar(&h);
assert_eq!(start.visible_lines, 1, "one line of text shows a one-line bar");
assert!(
start.handle.height() > 0.0 && (start.handle.bottom() - start.field.bottom()).abs() <= 4.0,
"the handle is the bar's bottom edge: {start:?}"
);
// Drag the edge down about five and a half rows: the bar snaps to whole lines.
let row = 17.0;
drag(&mut h, start.handle.center(), start.handle.center() + vec2(0.0, 5.5 * row));
let lines = h.state().ui.formula_bar_lines;
assert!(expanded(&h), "a taller bar is expanded");
assert!((6..=7).contains(&lines), "about six more lines: {lines}");
assert_eq!(bar(&h).visible_lines, lines, "the field shows the dragged lines: {:?}", bar(&h));
// Collapse, then expand: back to the dragged height, not the default five lines.
ctrl_shift_u(&mut h, CTRL_SHIFT);
h.run_steps(20);
assert!(!expanded(&h));
assert_eq!(bar(&h).visible_lines, 1);
ctrl_shift_u(&mut h, CTRL_SHIFT);
h.run_steps(20);
assert_eq!(bar(&h).visible_lines, lines, "expanding returns to the dragged height");
// Dragging the edge up to one line collapses the bar and keeps the expanded height.
let edge = bar(&h).handle.center();
drag(&mut h, edge, edge - vec2(0.0, 20.0 * row));
assert!(!expanded(&h), "a one-line bar is collapsed");
assert_eq!(bar(&h).visible_lines, 1);
assert_eq!(h.state().ui.formula_bar_lines, lines);
}
#[test]
fn expand_formula_bar_with_lines_sets_the_expanded_height() {
let mut h = harness();
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 8})), Ok(json!({"on": true, "lines": 8})));
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 1})), Ok(json!({"on": false, "lines": 8})), "one line collapses");
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": true})));
assert_eq!(h.state().ui.formula_bar_lines, 8, "expanding returns to the set height");
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line");
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped");
}
#[test]
fn an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window() {
// At 400 pt the title bar and ribbon leave about 236 pt: half the window as a formula bar
// would leave the status bar and sheet tabs no room for any of the grid.
let mut session = Session::new();
session.new_workbook();
let mut h = crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 400.0)), crate::common::app(session), 4);
h.state_mut().run("view.expandFormulaBar", json!({"lines": 100})).unwrap();
h.run_steps(60);
let cells = h.state().grid.cells_rect.expect("the grid is drawn");
assert!(cells.height() >= 40.0, "a couple of rows stay visible under a {} pt bar: {cells:?}", bar(&h).height);
assert!(bar(&h).visible_lines > 1, "the bar is still expanded");
}
@@ -173,3 +173,148 @@ fn shift_does_not_extend_a_row_reference_across_sheets() {
[("6:6".into(), Some("Other sheet".into())),]
);
}
fn press_and_drag(h: &mut egui_kittest::Harness<'_, SheetApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut()
.events
.extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]);
h.step();
drag_to(h, to);
}
fn drag_to(h: &mut egui_kittest::Harness<'_, SheetApp>, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(to));
h.step();
}
fn release(h: &mut egui_kittest::Harness<'_, SheetApp>, at: Pos2) {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
}
fn text<'a>(h: &'a egui_kittest::Harness<'_, SheetApp>) -> &'a str {
h.state().editor.as_ref().map_or("", |ed| ed.text.as_str())
}
#[test]
fn dragging_over_column_headers_extends_the_reference() {
let mut h = harness();
let (c, e, a, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 0), header_center(&h, false, 3));
press_and_drag(&mut h, c, e, Modifiers::NONE);
assert_eq!(text(&h), "=SUM(C:E");
// Back past the press: the reference follows the pointer instead of keeping E.
drag_to(&mut h, a);
assert_eq!(text(&h), "=SUM(A:C");
// The release position counts even without a movement frame before it.
release(&mut h, d);
assert_eq!(text(&h), "=SUM(C:D");
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8");
}
#[test]
fn dragging_over_row_headers_extends_the_reference() {
let mut h = harness();
let (r3, r6, r2) = (header_center(&h, true, 2), header_center(&h, true, 5), header_center(&h, true, 1));
press_and_drag(&mut h, r3, r6, Modifiers::NONE);
assert_eq!(text(&h), "=SUM(3:6");
release(&mut h, r2);
assert_eq!(text(&h), "=SUM(2:3");
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8");
}
#[test]
fn header_drag_on_another_sheet_keeps_the_qualifier() {
let mut h = harness();
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
h.run_steps(2);
let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3));
press_and_drag(&mut h, b, d, Modifiers::NONE);
assert_eq!(text(&h), "=SUM('Other sheet'!B:D");
}
#[test]
fn header_drag_with_command_keeps_the_earlier_argument() {
let mut h = harness();
let (a, e, f) = (header_center(&h, false, 0), header_center(&h, false, 4), header_center(&h, false, 5));
click(&mut h, a);
press_and_drag(&mut h, e, f, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE });
assert_eq!(text(&h), "=SUM(A:A,E:F");
}
#[test]
fn header_drag_with_shift_keeps_the_far_end_of_the_reference() {
let mut h = harness();
let (c, e, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 3));
click(&mut h, c);
press_and_drag(&mut h, e, d, Modifiers { shift: true, ..Modifiers::NONE });
assert_eq!(text(&h), "=SUM(C:D");
}
#[test]
fn clicks_on_headers_replace_the_reference_after_a_drag_too() {
let mut h = harness();
let (c, e, g) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 6));
press_and_drag(&mut h, c, e, Modifiers::NONE);
release(&mut h, e);
assert_eq!(text(&h), "=SUM(C:E");
click(&mut h, g);
assert_eq!(text(&h), "=SUM(G:G");
}
fn shift_arrow(h: &mut egui_kittest::Harness<'_, SheetApp>, key: egui::Key) {
let modifiers = Modifiers { shift: true, ..Modifiers::NONE };
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
h.step();
}
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
#[test]
fn shift_arrow_after_a_header_drag_moves_the_dragged_end() {
let mut h = harness();
let (c, e) = (header_center(&h, false, 2), header_center(&h, false, 4));
press_and_drag(&mut h, c, e, Modifiers::NONE);
release(&mut h, e);
assert_eq!(text(&h), "=SUM(C:E");
shift_arrow(&mut h, egui::Key::ArrowRight);
assert_eq!(text(&h), "=SUM(C:F");
// Dragged leftwards, the dragged end is the first column.
let mut h = harness();
let (e, c) = (header_center(&h, false, 4), header_center(&h, false, 2));
press_and_drag(&mut h, e, c, Modifiers::NONE);
release(&mut h, c);
assert_eq!(text(&h), "=SUM(C:E");
shift_arrow(&mut h, egui::Key::ArrowLeft);
assert_eq!(text(&h), "=SUM(B:E");
let mut h = harness();
let (r1, r3) = (header_center(&h, true, 0), header_center(&h, true, 2));
press_and_drag(&mut h, r1, r3, Modifiers::NONE);
release(&mut h, r3);
assert_eq!(text(&h), "=SUM(1:3");
shift_arrow(&mut h, egui::Key::ArrowDown);
assert_eq!(text(&h), "=SUM(1:4");
}
#[test]
fn shift_arrow_pointing_on_another_sheet_keeps_the_qualifier() {
let mut h = harness();
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
h.run_steps(2);
let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3));
press_and_drag(&mut h, b, d, Modifiers::NONE);
release(&mut h, d);
assert_eq!(text(&h), "=SUM('Other sheet'!B:D");
let app = h.state_mut();
let mut ed = app.editor.take().unwrap();
gridcraft_ui_egui::grid::point_move(app, &mut ed, 0, 1, true, false);
app.editor = Some(ed);
assert_eq!(text(&h), "=SUM('Other sheet'!B:E");
}
+33
View File
@@ -66,3 +66,36 @@ fn dragging_the_handles_freezes_moves_and_unfreezes() {
drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0));
assert_eq!(freeze(&h), None);
}
/// The last frozen column ends at the freeze line however far the other columns scroll, while
/// the next column's left edge moves with the scroll (#2544).
#[test]
fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() {
let mut h = harness();
h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C3"})).unwrap();
h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 200.0);
h.run_steps(2);
let g = geo(&h);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
let split = g.cells.min + egui::vec2(g.frozen_w, g.frozen_h);
assert!((g.x_end(&sh, 1) - split.x).abs() < 0.01 && (g.y_end(&sh, 1) - split.y).abs() < 0.01);
assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes");
assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2));
}
/// Dropping the column handle on the left part of the last frozen column while scrolled freezes
/// up to its left edge: the column's right edge is the freeze line, not the next column's
/// scrolled position (#2544).
#[test]
fn dragging_the_column_handle_when_scrolled_measures_the_last_frozen_column() {
let mut h = harness();
h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C1"})).unwrap();
h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 0.0);
h.run_steps(2);
let g = geo(&h);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
let (cols, _) = freeze_handles(&g);
assert!(g.x(&sh, 2) < g.x(&sh, 1), "C is scrolled left of B");
drag(&mut h, cols.center(), pos2(g.x(&sh, 1) + 10.0, g.cells.top() + 40.0));
assert_eq!(freeze(&h), Some((0, 1)));
}
+45
View File
@@ -0,0 +1,45 @@
//! Committed ink is painted as the pen previews it, at any zoom.
use egui::Shape;
use egui::epaint::ColorMode;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
/// The colour of the ink stroke drawn from (40, 40) to (200, 40) sheet points, `width` points
/// wide: the two-point horizontal path whose width is `width` at its own scale (160 pt long).
fn painted_ink(h: &egui_kittest::Harness<'static, SheetApp>, width: f32) -> Option<egui::Color32> {
h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Path(p) if p.points.len() == 2 && (p.points[0].y - p.points[1].y).abs() < 0.01 => {
let z = (p.points[1].x - p.points[0].x).abs() / 160.0;
match p.stroke.color {
ColorMode::Solid(c) if z > 0.0 && (p.stroke.width - width * z).abs() < 0.01 => Some(c),
_ => None,
}
}
_ => None,
})
}
#[test]
fn ink_translucency_follows_the_pen_not_the_zoom() {
// A highlighter (over 8 pt) is translucent and a pen isn't, whatever the zoom: the committed
// stroke used its width on screen, so at 50% a 12 pt highlighter turned opaque and at 200% a
// 6 pt pen turned translucent, unlike the pen's preview.
for (width, zoom, translucent) in [(12.0_f32, 50, true), (6.0, 200, false), (12.0, 100, true), (6.0, 100, false)] {
let mut s = Session::new();
s.new_workbook();
s.execute("view.zoom", json!({"percent": zoom})).unwrap();
s.execute("draw.stroke", json!({"points": [[40, 40], [200, 40]], "color": "#FFFF00", "width": width})).unwrap();
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
crate::common::app(s),
);
h.run_steps(4);
let c = painted_ink(&h, width).unwrap_or_else(|| panic!("the {width} pt stroke is painted at {zoom}%"));
assert_eq!(c.a() < 255, translucent, "{width} pt at {zoom}%: {c:?}");
}
}
@@ -100,6 +100,11 @@ fn windows_and_mac_variants() {
assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup"));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None);
// Ctrl+. is Next Corner of Selection, with Control on a Mac too (#1442); Cmd+Shift+. stays
// Increase Font Size.
assert_eq!(lookup(Key::Period, Some(Key::Period), cmd, false), run("selection.nextCorner"));
assert_eq!(lookup(Key::Period, Some(Key::Period), ctrl, true), run("selection.nextCorner"));
assert_eq!(lookup(Key::Period, Some(Key::Period), Modifiers { shift: true, ..mac_cmd }, true), run("home.increaseFontSize"));
// Plain keys stay with the grid (typing, navigation).
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
+303 -1
View File
@@ -2754,6 +2754,308 @@ fn keytip_fp_() {
assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print");
}
#[test]
fn keytip_ir_() {
let mut h = harness(blank());
sequence(&mut h, "IR", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "IR must journal home.insertRows");
}
#[test]
fn keytip_ic_() {
let mut h = harness(blank());
sequence(&mut h, "IC", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "IC must journal home.insertColumns");
}
#[test]
fn keytip_ie_() {
let mut h = harness(blank());
sequence(&mut h, "IE", false);
assert!(h.state().dialog.is_some(), "IE must open the insertCells dialog");
}
#[test]
fn keytip_iw_() {
let mut h = harness(blank());
sequence(&mut h, "IW", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "IW must journal home.insertSheet");
}
#[test]
fn keytip_if_() {
let mut h = harness(blank());
sequence(&mut h, "IF", false);
assert!(h.state().dialog.is_some(), "IF must open the insertFunction dialog");
}
#[test]
fn keytip_ind_() {
let mut h = harness(blank());
sequence(&mut h, "IND", false);
assert!(h.state().dialog.is_some(), "IND must open the defineName dialog");
}
#[test]
fn keytip_is_() {
let mut h = harness(blank());
sequence(&mut h, "IS", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "IS must route to its control without an error box");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_ed_() {
let mut h = harness(blank());
sequence(&mut h, "ED", false);
assert!(h.state().dialog.is_some(), "ED must open the deleteCells dialog");
}
#[test]
fn keytip_em_() {
let mut h = harness(blank());
sequence(&mut h, "EM", false);
assert!(h.state().dialog.is_some(), "EM must open the moveSheet dialog");
}
#[test]
fn keytip_el_() {
let mut h = harness(blank());
sequence(&mut h, "EL", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "EL must invoke home.deleteSheet and surface its missing-params error");
}
#[test]
fn keytip_eaa_() {
let mut h = harness(blank());
sequence(&mut h, "EAA", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "EAA must journal edit.clearAll");
}
#[test]
fn keytip_eac_() {
let mut h = harness(blank());
sequence(&mut h, "EAC", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "EAC must journal edit.clearContents");
}
#[test]
fn keytip_eam_() {
let mut h = harness(blank());
sequence(&mut h, "EAM", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "EAM must journal edit.clearComments");
}
#[test]
fn keytip_ef_() {
let mut h = harness(blank());
sequence(&mut h, "EF", false);
assert!(h.state().dialog.is_some(), "EF must open the find dialog");
}
#[test]
fn keytip_eg_() {
let mut h = harness(blank());
sequence(&mut h, "EG", false);
assert!(h.state().dialog.is_some(), "EG must open the goTo dialog");
}
#[test]
fn keytip_ora_() {
let mut h = harness(blank());
sequence(&mut h, "ORA", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "ORA must journal home.autofitRowHeight");
}
#[test]
fn keytip_ore_() {
let mut h = harness(blank());
sequence(&mut h, "ORE", false);
assert!(h.state().dialog.is_some(), "ORE must open the rowHeight dialog");
}
#[test]
fn keytip_orh_() {
let mut h = harness(blank());
sequence(&mut h, "ORH", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "ORH must journal home.hideRows");
}
#[test]
fn keytip_oru_() {
let mut h = harness(blank());
sequence(&mut h, "ORU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "ORU must journal home.unhideRows");
}
#[test]
fn keytip_ocw_() {
let mut h = harness(blank());
sequence(&mut h, "OCW", false);
assert!(h.state().dialog.is_some(), "OCW must open the columnWidth dialog");
}
#[test]
fn keytip_och_() {
let mut h = harness(blank());
sequence(&mut h, "OCH", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "OCH must journal home.hideColumns");
}
#[test]
fn keytip_ocu_() {
let mut h = harness(blank());
sequence(&mut h, "OCU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "OCU must journal home.unhideColumns");
}
#[test]
fn keytip_ohr_() {
let mut h = harness(blank());
sequence(&mut h, "OHR", false);
assert!(h.state().dialog.is_some(), "OHR must open the renameSheet dialog");
}
#[test]
fn keytip_ohh_() {
let mut h = harness(blank());
sequence(&mut h, "OHH", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHH must invoke sheet.hide and surface its missing-params error");
}
#[test]
fn keytip_ohu_() {
let mut h = harness(blank());
sequence(&mut h, "OHU", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHU must invoke sheet.unhide and surface its missing-params error");
}
#[test]
fn keytip_oe_() {
let mut h = harness(blank());
sequence(&mut h, "OE", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "OE must journal home.formatCells");
}
#[test]
fn keytip_od_() {
let mut h = harness(blank());
sequence(&mut h, "OD", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "OD must journal home.conditionalFormat");
}
#[test]
fn keytip_dff_() {
let mut h = harness(blank());
sequence(&mut h, "DFF", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "DFF must journal data.filter");
}
#[test]
fn keytip_ds_() {
let mut h = harness(blank());
sequence(&mut h, "DS", false);
assert!(h.state().dialog.is_some(), "DS must open the sort dialog");
}
#[test]
fn keytip_dgg_() {
let mut h = harness(blank());
sequence(&mut h, "DGG", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "DGG must journal data.group");
}
#[test]
fn keytip_dgu_() {
let mut h = harness(blank());
sequence(&mut h, "DGU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "DGU must journal data.ungroup");
}
#[test]
fn keytip_dgs_() {
let mut h = harness(blank());
sequence(&mut h, "DGS", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "DGS must journal data.showDetail");
}
#[test]
fn keytip_dgh_() {
let mut h = harness(blank());
sequence(&mut h, "DGH", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "DGH must journal data.hideDetail");
}
#[test]
fn keytip_dl_() {
let mut h = harness(blank());
sequence(&mut h, "DL", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "DL must journal data.validation");
}
#[test]
fn keytip_dt_() {
let mut h = harness(blank());
sequence(&mut h, "DT", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "DT must invoke data.dataTable and surface its missing-params error");
}
#[test]
fn keytip_vn_() {
let mut h = harness(blank());
sequence(&mut h, "VN", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "VN must journal view.normal");
}
#[test]
fn keytip_vp_() {
let mut h = harness(blank());
sequence(&mut h, "VP", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "VP must journal view.pageBreakPreview");
}
#[test]
fn keytip_vz_() {
let mut h = harness(blank());
sequence(&mut h, "VZ", false);
assert!(h.state().dialog.is_some(), "VZ must open the zoom dialog");
}
#[test]
fn keytip_to_() {
let mut h = harness(blank());
sequence(&mut h, "TO", false);
assert!(h.state().backstage.is_some(), "TO must open the Options page");
}
#[test]
fn keytip_tua_() {
let mut h = harness(blank());
sequence(&mut h, "TUA", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "TUA must journal formulas.removeArrows");
}
#[test]
fn keytip_tud_() {
let mut h = harness(blank());
sequence(&mut h, "TUD", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUD must run formulas.traceDependents cleanly");
}
#[test]
fn keytip_tut_() {
let mut h = harness(blank());
sequence(&mut h, "TUT", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUT must run formulas.tracePrecedents cleanly");
}
#[test]
fn keytip_fu_() {
let mut h = harness(blank());
sequence(&mut h, "FU", false);
assert!(h.state().dialog.is_some(), "FU must open the pageSetup dialog");
}
#[test]
fn keytip_q_() {
let mut h = harness(blank());
@@ -2766,7 +3068,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
const EXPECTED: usize = 367;
const EXPECTED: usize = 410;
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
}
+112
View File
@@ -0,0 +1,112 @@
//! Excel 2003 menu access keys that open a ribbon control rather than run a command (#941).
use egui::os::OperatingSystem;
use egui::{Event, Key, Modifiers};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
fn press(h: &mut egui_kittest::Harness<'static, SheetApp>, key: Key) {
let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
h.input_mut().events.extend([ev(true), Event::Text(key.name().to_ascii_lowercase()), ev(false)]);
h.run_steps(2);
}
#[test]
fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() {
// Insert > Symbol picks a symbol; running insert.symbol with no symbol set the active cell
// to its own text again, re-reading it (a Python cell lost its code, text could turn into a
// number). From the Home tab the keys show the Insert tab's gallery instead.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "'007"})).unwrap();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(4);
for key in [Key::F10, Key::I, Key::S] {
press(&mut h, key);
}
h.run_steps(4);
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "nothing is inserted until a symbol is picked");
assert_eq!(h.state().ui.ribbon_tab, "Insert");
assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open");
}
#[test]
fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() {
// With the Insert tab already chosen but the ribbon collapsed, the gallery's control isn't
// drawn: the keys expand the ribbon first.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap();
h.state_mut().ui.ribbon_collapsed = true;
h.run_steps(4);
for key in [Key::F10, Key::I, Key::S] {
press(&mut h, key);
}
h.run_steps(4);
assert!(!h.state().ui.ribbon_collapsed);
assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open");
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"));
}
#[test]
fn a_waiting_key_tip_request_stays_with_its_window() {
// A request waiting for the frame that draws its control belongs to the window whose keys
// made it: another window drawn first must not take it.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(2);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().show_window(second);
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
h.state_mut().show_window(first);
assert_eq!(h.state().ui.keytip_invoke_next, None, "the first window doesn't see the second window's request");
h.state_mut().show_window(second);
assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol"));
}
#[test]
fn a_hidden_window_drops_its_waiting_key_tip_request() {
// A hidden window isn't drawn, so its waiting request would fire when it was shown again,
// long after the keys: hiding the window drops it.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(2);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().show_window(second);
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(first);
h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(second);
assert_eq!(h.state().ui.keytip_invoke_next, None);
}
#[test]
fn a_window_hidden_from_another_window_drops_its_parked_request() {
// Hidden while another window is in front (its request already parked), the window still
// loses the request, so showing it again doesn't open the control.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(2);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().show_window(second);
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
h.state_mut().enter_window(first);
h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(first);
h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(second);
assert_eq!(h.state().ui.keytip_invoke_next, None);
}
+3
View File
@@ -7,6 +7,7 @@
mod common;
mod accounting_fill;
mod active_row_highlight;
mod advanced_filter;
mod argument_names;
mod autosave_persist;
@@ -58,11 +59,13 @@ mod hyperlink_ctrl_click;
mod hyperlink_dialog;
mod in_cell_editor_size;
mod in_cell_editor_wrap;
mod ink_render;
mod insert_copied_cells;
mod interaction;
mod interface_scale;
mod keyboard_shortcuts;
mod keytips_generated;
mod legacy_keytips;
mod macro_editor;
mod macros_ui;
mod menu_hover;
@@ -54,3 +54,50 @@ fn paste_link_pastes_references_at_once() {
assert_eq!(c1["value"], json!(2.0));
assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}");
}
fn press(h: &mut Harness, key: egui::Key) {
for pressed in [true, false] {
h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE });
}
h.run_steps(2);
}
fn value(h: &Harness, field: &str) -> serde_json::Value {
h.state().dialog.as_ref().and_then(|d| d.values.get(field).cloned()).unwrap_or_default()
}
#[test]
fn every_option_has_its_excel_access_key() {
// #1482: only F, V, T, C and E worked.
let mut h = harness();
for (key, field, want) in [
(egui::Key::N, "what", json!("validation")),
(egui::Key::X, "what", json!("allExceptBorders")),
(egui::Key::W, "what", json!("columnWidths")),
(egui::Key::R, "what", json!("formulasAndNumberFormats")),
(egui::Key::U, "what", json!("valuesAndNumberFormats")),
(egui::Key::A, "what", json!("all")),
(egui::Key::D, "operation", json!("add")),
(egui::Key::S, "operation", json!("subtract")),
(egui::Key::I, "operation", json!("divide")),
(egui::Key::O, "operation", json!("none")),
(egui::Key::M, "operation", json!("multiply")),
(egui::Key::B, "skipBlanks", json!(true)),
] {
press(&mut h, key);
assert_eq!(value(&h, field), want, "{key:?}");
}
press(&mut h, egui::Key::B);
assert_eq!(value(&h, "skipBlanks"), json!(false), "B flips Skip blanks back");
// V then Enter: values multiplied, as with the mouse.
press(&mut h, egui::Key::V);
press(&mut h, egui::Key::Enter);
assert!(h.state().dialog.is_none());
assert_eq!(cell(&mut h, "C1")["value"], json!(20.0));
// L is the Paste Link button: it pastes at once.
let mut h = harness();
press(&mut h, egui::Key::L);
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(cell(&mut h, "C1")["formula"].as_str().is_some_and(|f| f.contains("A1")));
}
+67
View File
@@ -151,3 +151,70 @@ fn each_workbook_keeps_its_own_tab_share() {
assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h));
assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share");
}
/// The tab bar of a workbook whose window has `command` run with `{on: false}` first.
fn harness_without(command: &str) -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Tab00"})).unwrap();
s.execute(command, json!({"on": false})).unwrap();
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(900.0, 120.0)).build_ui_state(
move |ui, app: &mut SheetApp| {
app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0)));
gridcraft_ui_egui::tabs::sheet_tabs(app, ui);
},
crate::common::app(s),
);
h.run_steps(20);
h
}
fn shows_tab_name(h: &Harness) -> bool {
h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == "Tab00"))
}
#[test]
fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() {
// #3101: File › Options › Advanced, Display options for this workbook.
let h = harness_without("view.showSheetTabs");
assert!(!shows_tab_name(&h), "no sheet tabs");
assert!(h.state().grid.tab_split.is_none(), "nothing to split without tabs");
let bar = h.state().grid.hscroll.expect("the scroll bar stays");
assert!(bar.left() < 40.0, "the scroll bar takes the bar from its left edge: {bar:?}");
let h = harness_without("view.showHorizontalScrollBar");
assert!(shows_tab_name(&h), "the tabs stay");
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle");
// Both off: no bar at all.
let mut s = Session::new();
s.new_workbook();
s.execute("view.showSheetTabs", json!({"on": false})).unwrap();
s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap();
let mut h = crate::common::builder()
.with_size(vec2(900.0, 120.0))
.build_ui_state(|ui, app: &mut SheetApp| gridcraft_ui_egui::tabs::sheet_tabs(app, ui), crate::common::app(s));
h.run_steps(5);
assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all");
}
#[test]
fn hiding_the_controls_clears_their_cached_rectangles() {
// Turned off on a live window, the controls drop the rectangles of the frame that drew them.
let mut h = harness(900.0, 1);
assert!(h.state().grid.tab_split.is_some() && h.state().grid.hscroll.is_some(), "both controls drawn first");
h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.tab_split.is_none(), "no split handle once the tabs are hidden");
assert!(h.state().grid.hscroll.is_some(), "the scroll bar stays");
h.state_mut().run("view.showSheetTabs", json!({"on": true})).unwrap();
h.state_mut().run("view.showHorizontalScrollBar", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle");
h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all");
}
@@ -53,6 +53,18 @@ fn text_clips(h: &Harness, text: &str) -> Vec<Rect> {
.collect()
}
/// Painted extents of the text shapes whose text is `text`, cut to their clip rects.
fn text_visible_rects(h: &Harness, text: &str) -> Vec<Rect> {
h.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == text => Some(t.visual_bounding_rect().intersect(s.clip_rect)),
_ => None,
})
.collect()
}
fn cell_rect(h: &Harness, cell: &str) -> Rect {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
@@ -390,3 +402,71 @@ fn wrapped_text_clipping_survives_partial_vertical_scroll() {
clip={clip:?}, expected={expected_bottom:.2}"
);
}
/// Issue #2544: text in a frozen column keeps its whole cell while the other columns scroll.
#[test]
fn frozen_column_text_keeps_its_cell_when_scrolled() {
let mut h = harness(|s| {
s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "v"})).unwrap();
s.execute("view.freezeFirstColumn", json!({})).unwrap();
});
for col in [1, 2, 3, 8] {
scroll_to_col(&mut h, col);
let a1 = cell_rect(&h, "A1");
let clips = text_clips(&h, LEFT);
assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}");
assert!(
(clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5,
"A1's text spans A1 at column {col}: {clips:?} vs {a1:?}"
);
let visible = text_visible_rects(&h, LEFT);
assert!(visible.len() == 1 && visible[0].is_positive(), "A1's text shows inside its clip at column {col}: {visible:?}");
}
}
/// Issue #2544: with two frozen columns, A1's text keeps overflowing across the whole of empty B1.
#[test]
fn frozen_text_overflows_to_the_freeze_line_when_scrolled() {
let mut h = harness(|s| {
s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap();
s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap();
});
for col in [2, 3, 4, 10] {
scroll_to_col(&mut h, col);
let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1"));
let clips = text_clips(&h, LEFT);
assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}");
assert!(
(clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - b1.right()).abs() <= 0.5,
"A1's text spans A1:B1 at column {col}: {clips:?} vs {a1:?} {b1:?}"
);
}
}
/// Issue #2544: the frozen columns' gridlines reach the freeze line, and none is drawn inside
/// them at a scrolled column's edge.
#[test]
fn frozen_column_gridlines_stay_put_when_scrolled() {
let mut h = harness(|s| {
s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap();
});
scroll_to_col(&mut h, 6);
let (a5, b5) = (cell_rect(&h, "A5"), cell_rect(&h, "B5"));
let frozen = |s: &egui::epaint::ClippedShape| s.clip_rect.right() <= b5.right() + 0.5 && s.clip_rect.left() <= a5.left();
let segments: Vec<[egui::Pos2; 2]> = h
.output()
.shapes
.iter()
.filter(|s| frozen(s))
.filter_map(|s| match &s.shape {
Shape::LineSegment { points, .. } => Some(*points),
_ => None,
})
.collect();
let row5 = segments.iter().find(|p| (p[0].y - (a5.top() - 0.5).round()).abs() <= 1.0 && p[0].y == p[1].y);
assert!(row5.is_some_and(|p| p[1].x >= b5.right() - 1.0), "row 5's gridline reaches the freeze line: {row5:?} vs {b5:?}");
let verticals: Vec<f32> = segments.iter().filter(|p| p[0].x == p[1].x && p[0].y < b5.bottom()).map(|p| p[0].x).collect();
let expected = [a5.left(), a5.right(), b5.right()].map(|x| x.round() - 0.5);
assert!(verticals.iter().all(|x| expected.iter().any(|e| (x - e).abs() <= 0.5)), "only A and B's edges: {verticals:?} vs {expected:?}");
}
+4 -2
View File
@@ -366,9 +366,11 @@ fn is_ref(f: &str) -> bool {
/// when it isn't a name, or names a sheet the workbook doesn't have.
fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option<usize>, String, Option<&'a DefinedName>)> {
let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None };
match n.split_once('!') {
// The parser has already unquoted the sheet (`'Bob''s'!Name` → `Bob's!Name`, #2012): split
// at the last `!` (`'a!b'!Name`) and keep the name as is (`' Sales '` keeps its spaces).
match n.rsplit_once('!') {
Some((s, bare)) => {
let i = wb.sheet_index(s.trim_matches('\''))?;
let i = wb.sheet_index(s)?;
Some((Some(i), bare.to_string(), wb.name(bare, i)))
}
None => {
+8 -2
View File
@@ -25,10 +25,13 @@ pub const XF_EXT_URI: &str = "{C7286773-470A-42A8-94C5-96B5CB345126}";
/// The bags for one checkbox cell control, referenced as `xfComplement i="0"`: Checkbox (0),
/// XFControls → 0 (1), XFComplement → 1 (2), XFComplements mapping index 0 → 2 (3). Every
/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires.
/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. The XFComplements bag is
/// the one `xfComplement` indexes into, and Excel finds it by its `extRef`: without
/// `extRef="XFComplementsMapperExtRef"` it repairs the workbook ("Format from /xl/styles.xml")
/// and drops every checkbox (#2630).
pub fn checkbox_part() -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<FeaturePropertyBags xmlns=\"{NS}\" count=\"4\"><bag type=\"Checkbox\"/><bag type=\"XFControls\"><bagId k=\"CellControl\">0</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\"><a k=\"MappedFeaturePropertyBags\"><bagId>2</bagId></a></bag></FeaturePropertyBags>"
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<FeaturePropertyBags xmlns=\"{NS}\" count=\"4\"><bag type=\"Checkbox\"/><bag type=\"XFControls\"><bagId k=\"CellControl\">0</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\" extRef=\"XFComplementsMapperExtRef\"><a k=\"MappedFeaturePropertyBags\"><bagId>2</bagId></a></bag></FeaturePropertyBags>"
)
}
@@ -73,6 +76,9 @@ mod tests {
fn written_chain_resolves_and_hostile_chains_do_not() {
let root = crate::xml::parse(checkbox_part().as_bytes()).unwrap_or_default();
assert_eq!(checkbox_complements(&root), vec![true]);
// #2630: Excel repairs the file unless the mapping bag carries this extRef.
let mapper = root.kids("bag").find(|b| b.attr("type") == Some("XFComplements"));
assert_eq!(mapper.and_then(|b| b.attr("extRef")), Some("XFComplementsMapperExtRef"));
// Out-of-range, wrong-type and self references resolve to false without panicking.
let bad = format!(
"<FeaturePropertyBags xmlns=\"{NS}\"><bag type=\"XFControls\"><bagId k=\"CellControl\">99</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\"><a k=\"MappedFeaturePropertyBags\"><bagId>1</bagId><bagId>4294967295</bagId><bagId>x</bagId></a></bag></FeaturePropertyBags>"
+1 -1
View File
@@ -47,7 +47,7 @@ use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited};
pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited};
pub use ods::read_ods;
pub use package::MAX_PART;
pub use passthrough::rebase_links;
+1 -1
View File
@@ -317,7 +317,7 @@ impl Import {
return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into()));
}
let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default();
let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len());
let mut name = crate::read::SheetNames::of(&self.wb).unique(original.trim_matches('\''), self.wb.sheets.len());
if self.wb.check_sheet_name(&name, None).is_err() {
name = self.wb.next_sheet_name();
}
+41 -8
View File
@@ -1,5 +1,6 @@
//! Reading the workbook part and everything hanging off it.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use gridcraft_core::{DateSystem, RangeRef};
@@ -200,6 +201,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
if p.flag("date1904", false) {
cx.date_system = DateSystem::D1904;
}
wb.remove_personal_info = p.flag("filterPrivacy", false);
wb.show_objects = match p.attr("showObjects") {
Some("none") => ShowObjects::None,
Some("placeholders") => ShowObjects::Placeholders,
@@ -221,6 +223,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
wb.calc.set_max_change(d);
}
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
wb.calc.r1c1 = c.attr("refMode") == Some("R1C1");
}
if let Some(p) = xml.child("workbookProtection") {
wb.protected_structure = p.flag("lockStructure", false);
@@ -304,6 +307,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
let mut file_to_model: Vec<Option<usize>> = vec![];
let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels);
let sheet_els: Vec<El> = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default();
let mut names = SheetNames::default();
for (i, se) in sheet_els.iter().enumerate() {
let name = se.attr("name").unwrap_or("").to_string();
let rel = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).cloned();
@@ -322,7 +326,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
file_to_model.push(None);
continue;
}
let name = unique_name(&wb, &name, i);
let name = names.unique(&name, i);
let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
Ok(s) => s,
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
@@ -455,15 +459,44 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
Ok((wb, cx.report))
}
pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String {
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
if base.trim().is_empty() {
base = format!("Sheet{}", i + 1);
/// The sheet names an import has assigned so far, for repairing repeated ones.
#[derive(Default)]
pub(crate) struct SheetNames {
/// Assigned names, ASCII-lowercased.
taken: HashSet<String>,
/// For each lowercased stem, the next suffix to try: a file full of repeated names would
/// otherwise retry every occupied suffix for every sheet.
next: HashMap<String, u64>,
}
impl SheetNames {
pub(crate) fn of(wb: &Workbook) -> Self {
SheetNames { taken: wb.sheets.iter().map(|s| s.name.to_ascii_lowercase()).collect(), next: HashMap::new() }
}
if wb.sheet_index(&base).is_none() {
return base;
/// A valid name for the i-th sheet, unique among the names assigned so far, which it joins.
pub(crate) fn unique(&mut self, name: &str, i: usize) -> String {
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
if base.trim().is_empty() {
base = format!("Sheet{}", i + 1);
}
let name = if self.taken.contains(&base.to_ascii_lowercase()) {
let stem: String = base.chars().take(25).collect();
let next = self.next.entry(stem.to_ascii_lowercase()).or_insert(2);
let found = (*next..).find(|n| !self.taken.contains(&format!("{stem} ({n})").to_ascii_lowercase()));
match found {
Some(n) => {
*next = n + 1;
format!("{stem} ({n})")
}
None => base,
}
} else {
base
};
self.taken.insert(name.to_ascii_lowercase());
name
}
(2..).map(|n| format!("{} ({n})", base.chars().take(25).collect::<String>())).find(|n| wb.sheet_index(n).is_none()).unwrap_or(base)
}
/// Strips a sheet qualifier (`'My Sheet'!$A$1` → `$A$1`).
+48 -24
View File
@@ -142,13 +142,22 @@ pub fn read(cx: &mut Ctx<'_>, workbook_rels: &[Rel]) -> Result<Vec<Option<Arc<Ce
let Some(image_rel_list) = read_xml(cx, &image_rels_path, "richValueRels")? else { return Ok(vec![]) };
let image_rels = cx.pkg.rels(&image_rels_path)?;
let structures = entries(cx, &structures, "s")?;
// Each structure's key layout, worked out once: every value would otherwise walk its
// structure's keys again. Only local-image structures matter here.
let layouts: Vec<Option<Layout>> = structures.iter().map(|s| Layout::of(s)).collect();
let image_rel_list = entries(cx, &image_rel_list, "rel")?;
// Relationships by id, a repeated id recorded as invalid (`None`), so a value's lookup is
// one probe rather than a scan of the whole table for duplicates.
let mut rels_by_id: HashMap<&str, Option<&Rel>> = HashMap::new();
for rel in &image_rels {
rels_by_id.entry(rel.id.as_str()).and_modify(|r| *r = None).or_insert(Some(rel));
}
let values = entries(cx, &data, "rv")?;
let mut pictures = Vec::with_capacity(values.len());
let mut cache = HashMap::new();
let mut total = 0usize;
for value in values {
let picture = read_value(cx, value, &structures, &image_rel_list, &image_rels, &mut cache, &mut total)?;
let picture = read_value(cx, value, &layouts, &image_rel_list, &rels_by_id, &mut cache, &mut total)?;
if picture.is_none() {
cx.warn("some rich values are unsupported or malformed; their scalar fallbacks were retained (cell pictures may be missing)");
}
@@ -188,43 +197,56 @@ pub fn read(cx: &mut Ctx<'_>, workbook_rels: &[Rel]) -> Result<Vec<Option<Arc<Ce
.collect())
}
/// Where a `_localImage` structure keeps the values a picture needs.
struct Layout {
keys: usize,
/// Index of the one `_rvRel:LocalImageIdentifier` key of type `i`, if exactly one.
image: Option<usize>,
/// Index of the one `Text` key of type `s`, if exactly one.
text: Option<usize>,
}
impl Layout {
fn of(structure: &El) -> Option<Layout> {
if structure.attr("t") != Some("_localImage") {
return None;
}
let keys: Vec<&El> = structure.kids("k").collect();
let one = |name: &str, ty: &str| {
let mut matches = keys.iter().enumerate().filter(|(_, k)| k.attr("n") == Some(name));
let (i, key) = matches.next()?;
(matches.next().is_none() && key.attr("t") == Some(ty)).then_some(i)
};
Some(Layout { keys: keys.len(), image: one("_rvRel:LocalImageIdentifier", "i"), text: one("Text", "s") })
}
}
fn read_value(
cx: &mut Ctx<'_>,
value: &El,
structures: &[&El],
layouts: &[Option<Layout>],
image_rel_list: &[&El],
image_rels: &[Rel],
rels_by_id: &HashMap<&str, Option<&Rel>>,
cache: &mut HashMap<(String, String), Arc<CellPicture>>,
total: &mut usize,
) -> Result<Option<Arc<CellPicture>>, IoError> {
let Some(structure) = value.attr_u32("s").and_then(|n| structures.get(n as usize)) else { return Ok(None) };
if structure.attr("t") != Some("_localImage") {
return Ok(None);
}
let keys: Vec<_> = structure.kids("k").collect();
let Some(Some(layout)) = value.attr_u32("s").and_then(|n| layouts.get(n as usize)) else { return Ok(None) };
let values: Vec<_> = value.kids("v").collect();
if keys.len() != values.len() {
if layout.keys != values.len() {
return Ok(None);
}
let lookup = |name: &str, ty: &str| {
let mut matches = keys.iter().zip(&values).filter(|(k, _)| k.attr("n") == Some(name));
let (key, value) = matches.next()?;
(matches.next().is_none() && key.attr("t") == Some(ty)).then_some(value.text.as_str())
};
let Some(rel_id) = lookup("_rvRel:LocalImageIdentifier", "i")
.and_then(|v| v.trim().parse::<usize>().ok())
.and_then(|n| image_rel_list.get(n))
.and_then(|rel| rel.attr("id"))
let lookup = |slot: Option<usize>| slot.and_then(|i| values.get(i)).map(|v| v.text.as_str());
let Some(rel_id) =
lookup(layout.image).and_then(|v| v.trim().parse::<usize>().ok()).and_then(|n| image_rel_list.get(n)).and_then(|rel| rel.attr("id"))
else {
return Ok(None);
};
let mut matches = image_rels.iter().filter(|rel| rel.id == rel_id);
let Some(rel) = matches.next() else { return Ok(None) };
if matches.next().is_some() || rel.kind != "image" || rel.external {
let Some(found) = rels_by_id.get(rel_id) else { return Ok(None) };
let Some(rel) = found.filter(|rel| rel.kind == "image" && !rel.external) else {
cx.warn("a cell picture has an external or invalid image relationship; kept its scalar fallback");
return Ok(None);
}
let alt = lookup("Text", "s").unwrap_or("").to_string();
};
let alt = lookup(layout.text).unwrap_or("").to_string();
let key = (normalize(&rel.target), alt.clone());
if let Some(picture) = cache.get(&key) {
return Ok(Some(picture.clone()));
@@ -349,7 +371,9 @@ fn metadata(count: usize, dynamic: bool) -> String {
}
xml.push_str("<metadataType name=\"XLRICHVALUE\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" cellMeta=\"0\"/></metadataTypes>");
if dynamic {
xml.push_str("<futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{BDBB8CDC-FA1E-496E-A857-3C3F30C029C3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata>");
// Lower case, as the dynamic-array-only metadata in `write.rs` spells it: Excel refuses
// a workbook whose XLDAPR extension URI is in upper case (#2339).
xml.push_str("<futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata>");
}
let _ = write!(xml, "<futureMetadata name=\"XLRICHVALUE\" count=\"{count}\">");
for i in 0..count {
+6 -1
View File
@@ -370,7 +370,12 @@ fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o
}
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
let kept = crate::passthrough::SheetKept::write(sheet, rels, out);
let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64);
// Margins are kept as f32: widen each through its shortest decimal, so 0.3 is written as
// `0.3`, as Excel writes it, and not as `0.30000001192092896` (#1609).
let m = |i: usize| {
let inches = p.margins.get(i).copied().unwrap_or(0.75);
num(inches.to_string().parse::<f64>().unwrap_or(f64::from(inches)))
};
let _ =
write!(s, "<pageMargins left=\"{}\" right=\"{}\" top=\"{}\" bottom=\"{}\" header=\"{}\" footer=\"{}\"/>", m(0), m(1), m(2), m(3), m(4), m(5));
s.push_str("<pageSetup");
+51 -6
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use super::*;
/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\`
@@ -21,16 +23,24 @@ pub(super) fn table_name(name: &str, used: &mut HashSet<String>, n: u32) -> Stri
pub(super) fn table_columns(t: &Table) -> Vec<String> {
let w = t.range.width() as usize;
let mut used = HashSet::new();
// The next suffix to try per lowercased base, so repeated headers don't retry every suffix
// already handed out.
let mut next: HashMap<String, u32> = HashMap::new();
(0..w)
.map(|i| {
let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base}{k}");
k += 1;
let lower = base.to_lowercase();
if used.insert(lower.clone()) {
return base;
}
let k = next.entry(lower).or_insert(2);
loop {
let name = format!("{base}{k}");
*k += 1;
if used.insert(name.to_lowercase()) {
return name;
}
}
name
})
.collect()
}
@@ -371,3 +381,38 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String {
s.push_str("</xml>");
s
}
#[cfg(test)]
mod tests {
use gridcraft_model::TableColumn;
use super::*;
#[test]
fn repeated_headers_are_renamed_without_retrying_suffixes() {
let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None };
let mut columns: Vec<TableColumn> = (0..10_000).map(|_| col("Total")).collect();
columns[1] = col("total3");
let t = Table {
id: 1,
name: "T".into(),
range: RangeRef::parse("A1:NTP2").unwrap(),
header_row: true,
totals_row: false,
columns,
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: false,
filter: vec![],
table_type: None,
connection_id: None,
};
let names = table_columns(&t);
assert_eq!(names.len(), 10_000);
assert_eq!(&names[..4], ["Total", "total3", "Total2", "Total4"]);
assert_eq!(names.iter().map(|n| n.to_lowercase()).collect::<HashSet<_>>().len(), 10_000);
}
}

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