mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 13:16:50 -06:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ea1fe080 | ||
|
|
0e7456c0d3 | ||
|
|
17b9f6ff01 | ||
|
|
52fa4406c1 | ||
|
|
24e49d18ad | ||
|
|
850dc9c772 | ||
|
|
f17179ceeb | ||
|
|
47fadb8dcf | ||
|
|
87a649c763 | ||
|
|
77b37df655 | ||
|
|
ffa08d3a31 | ||
|
|
50c7ee3971 | ||
|
|
f65e9f6bed | ||
|
|
6401899ebc | ||
|
|
e1a12fa337 | ||
|
|
846d5b98ee | ||
|
|
6194f8241d | ||
|
|
1e50c72698 | ||
|
|
b58846a549 | ||
|
|
1713334579 | ||
|
|
cb1fb89549 | ||
|
|
75f7df9da6 | ||
|
|
8356b33c78 | ||
|
|
2741680144 | ||
|
|
5b2921e616 | ||
|
|
9f22a06627 | ||
|
|
924d36e247 | ||
|
|
ae88b40f58 | ||
|
|
0217dbdfd8 | ||
|
|
91a1d18c91 | ||
|
|
58aa0f1df2 | ||
|
|
2660973286 | ||
|
|
44cc84edf2 | ||
|
|
f1b06a9481 | ||
|
|
bb6f38d95d | ||
|
|
0ad5d7d6e0 | ||
|
|
f77737b3ef | ||
|
|
a12e1e3296 | ||
|
|
c44971fcd5 | ||
|
|
f3d84d4119 | ||
|
|
b622394468 | ||
|
|
0eb2f1f6f0 | ||
|
|
a2e4b2b1da | ||
|
|
a75224617b | ||
|
|
1dd07b7f75 | ||
|
|
71669dae6d | ||
|
|
b745c136b2 | ||
|
|
9425358faa | ||
|
|
118cd15d93 | ||
|
|
010eddd25e | ||
|
|
7c10db07d9 | ||
|
|
3d41bdb956 | ||
|
|
28efc9dabf | ||
|
|
3fa6cb470b | ||
|
|
30126a709a | ||
|
|
46f6157800 | ||
|
|
2ae2329090 | ||
|
|
00edfd0690 | ||
|
|
3099df934e | ||
|
|
d0fe8b6423 | ||
|
|
07890b47f8 | ||
|
|
10bec74810 | ||
|
|
a005d8235f | ||
|
|
48d9ba264c | ||
|
|
89d8e81941 | ||
|
|
6fac430f55 | ||
|
|
7bc390d0fb | ||
|
|
f284f6a825 | ||
|
|
fb69da0068 | ||
|
|
b8d4cd5161 | ||
|
|
896b7f8e05 | ||
|
|
4e86fe7ad3 | ||
|
|
983d3d7e6a | ||
|
|
00b65d7e8c | ||
|
|
3557b9f6cd | ||
|
|
72c1d57d1e | ||
|
|
beda2a44dc | ||
|
|
ec841a0139 | ||
|
|
edea52e663 | ||
|
|
2c6e529b4d | ||
|
|
d743f21992 | ||
|
|
f9af0d88a5 | ||
|
|
14d6dabcaf | ||
|
|
05b77987cd | ||
|
|
deb167956c | ||
|
|
6ead1fb519 | ||
|
|
c22787e5bf | ||
|
|
e93d15b50a | ||
|
|
b389d9067a | ||
|
|
b47c83f6aa | ||
|
|
35067fa9ca | ||
|
|
86f4114514 | ||
|
|
e883c84e7b | ||
|
|
9fd7693d4e | ||
|
|
1ce4781d3e | ||
|
|
74b2bda75c | ||
|
|
4b2ee2e2dd | ||
|
|
45f06f50da | ||
|
|
d5aa6fbcbc | ||
|
|
450f457535 | ||
|
|
a0fa6c6e6e | ||
|
|
b0ab19701d | ||
|
|
d0fdf948db | ||
|
|
3b05abe05e | ||
|
|
09973e7831 | ||
|
|
672c102eb2 | ||
|
|
6db94d154a | ||
|
|
ea190adad6 | ||
|
|
5c20f16434 | ||
|
|
ab4d58ca6f | ||
|
|
74de1b32eb |
+20
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,16 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
||||
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
|
||||
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
|
||||
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
|
||||
| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
|
||||
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
|
||||
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
|
||||
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
|
||||
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
||||
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
|
||||
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
|
||||
@@ -147,10 +157,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
|
||||
| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
|
||||
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
|
||||
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
|
||||
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
|
||||
@@ -176,6 +189,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
|
||||
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
|
||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
||||
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
||||
@@ -221,6 +235,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
|
||||
@@ -230,6 +246,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
|
||||
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
|
||||
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
|
||||
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
|
||||
@@ -245,6 +263,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | UI building blocks; progress log row |
|
||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||
|
||||
@@ -248,10 +248,11 @@ impl Discovery {
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
if !walk(&p, 0, &mut out) {
|
||||
if !walk(&p, 0, &mut out, &mut seen) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
@@ -268,7 +269,20 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
}
|
||||
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
||||
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
||||
// depth cap only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if let Ok(m) = std::fs::metadata(dir)
|
||||
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||
return true;
|
||||
@@ -301,10 +315,10 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out) {
|
||||
if !walk(&p, depth + 1, out, seen) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
@@ -319,7 +333,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
|
||||
guarded.unwrap_or_else(|p| {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() }
|
||||
})
|
||||
}
|
||||
@@ -981,6 +995,12 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
// Several relationships name the same part; each fresh check decompressed it again, so
|
||||
// repeated references to one highly compressible part multiplied the work. The verdict is
|
||||
// remembered per (part, named content) and the decompression the package can cause is
|
||||
// budgeted as a whole (finding: repeated decompression).
|
||||
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
||||
let mut budget: u64 = 512 * 1024 * 1024;
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
@@ -994,7 +1014,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if !external
|
||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||
&& !holds_something(&mut zip, part, kind)
|
||||
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1013,15 +1033,41 @@ fn rels_source_dir(key: &str) -> Option<String> {
|
||||
|
||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
||||
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
|
||||
/// package's remaining checks may cause, and once it is spent the part counts as having content.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
|
||||
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
"comments" => Some(b"comment"),
|
||||
"vmldrawing" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let marker = match wanted {
|
||||
Some(b"comment") => "comment",
|
||||
Some(_) => "shape",
|
||||
None => "any",
|
||||
};
|
||||
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
|
||||
return *v;
|
||||
}
|
||||
let v = holds_something_uncached(zip, name, marker, budget);
|
||||
verdicts.insert((name.to_ascii_lowercase(), marker), v);
|
||||
v
|
||||
}
|
||||
|
||||
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
if *budget == 0 {
|
||||
return true;
|
||||
}
|
||||
let take = f.size().min(MAX_XML_PART).min(*budget);
|
||||
*budget -= take;
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
|
||||
let wanted: Option<&'static [u8]> = match marker {
|
||||
"comment" => Some(b"comment"),
|
||||
"shape" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
let mut in_root = false;
|
||||
loop {
|
||||
@@ -1321,7 +1367,8 @@ mod tests {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
assert!(walk(&path, 0, &mut discovery));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
|
||||
@@ -214,7 +214,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(w) = web_sys::window()
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
let _ = w.open_with_url_and_target(&url, "_blank");
|
||||
// With `noopener` the new browsing context can't hold a reference back to the
|
||||
// editor window and navigate it while the user reads the linked page.
|
||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
@@ -350,7 +352,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
|
||||
|
||||
if let Some(picker_fn) = show_picker_fn {
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
|
||||
@@ -180,11 +180,11 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let path = std::path::Path::new(suggested);
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
||||
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
|
||||
const TYPES: [(&str, &str); 4] =
|
||||
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
|
||||
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
|
||||
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn discover() -> Discover {
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
@@ -45,7 +45,7 @@ pub fn submit() -> Submit {
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
|
||||
@@ -96,3 +96,10 @@ sync_github_name = true
|
||||
|
||||
[people.tyrel]
|
||||
real_name = "Tyrel Haveman"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
+139
-10
@@ -22,6 +22,11 @@ const FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// range, so that formulas over them stay fast.
|
||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
const MAX_DEPTH: usize = 400;
|
||||
/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke
|
||||
/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers
|
||||
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
|
||||
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
|
||||
const LAMBDA_FUEL: u64 = MAX_CELLS;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Area {
|
||||
@@ -53,6 +58,24 @@ fn err(e: CellError) -> Ev {
|
||||
Ev::V(Value::Error(e))
|
||||
}
|
||||
|
||||
/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build.
|
||||
/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132
|
||||
/// calls in progress, so it still runs.
|
||||
const MAX_LAMBDA_CALLS: usize = 140;
|
||||
|
||||
/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it,
|
||||
/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames
|
||||
/// above the formula. A cell that another cell's formula computes on demand shares the budget of the
|
||||
/// outermost formula ([`Host::stack_top`]).
|
||||
const STACK_BUDGET: usize = 896 * 1024;
|
||||
|
||||
/// The address of a local variable, which shows how far down the native stack this call is.
|
||||
#[inline(always)]
|
||||
pub(crate) fn stack_position() -> usize {
|
||||
let marker = 0u8;
|
||||
std::hint::black_box(&marker) as *const u8 as usize
|
||||
}
|
||||
|
||||
/// What the evaluator needs from its surroundings.
|
||||
pub trait Host {
|
||||
fn workbook(&self) -> &Workbook;
|
||||
@@ -80,6 +103,13 @@ pub trait Host {
|
||||
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
false
|
||||
}
|
||||
/// While a formula is calculated on demand inside another one: `stack_position()` where the
|
||||
/// outermost one started, which the stack budget is counted from.
|
||||
fn stack_top(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
/// Called when a formula's evaluation runs past the stack budget.
|
||||
fn stack_exhausted(&mut self) {}
|
||||
}
|
||||
|
||||
pub struct Evaluator<'h> {
|
||||
@@ -88,6 +118,15 @@ pub struct Evaluator<'h> {
|
||||
pub at: CellRef,
|
||||
env: Vec<(String, Ev)>,
|
||||
depth: usize,
|
||||
/// LAMBDA calls in progress.
|
||||
calls: usize,
|
||||
/// `stack_position()` at the outermost `eval`.
|
||||
stack_top: usize,
|
||||
/// Lambda invocations this cell still has left. Every call spends one charge, so a
|
||||
/// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays)
|
||||
/// runs out of work after the same number of calls that a legitimately wide one could
|
||||
/// make, instead of an amount of work that squares with array nesting (#2382).
|
||||
fuel: u64,
|
||||
}
|
||||
|
||||
struct FnCtx<'a, 'h> {
|
||||
@@ -127,8 +166,20 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||
let mut ev = Evaluator {
|
||||
host: &mut *self.ev.host,
|
||||
sheet: area.sheet,
|
||||
at,
|
||||
env,
|
||||
depth: self.ev.depth,
|
||||
calls: self.ev.calls,
|
||||
stack_top: self.ev.stack_top,
|
||||
fuel: self.ev.fuel,
|
||||
};
|
||||
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
|
||||
// The child's spend counts against this cell's budget.
|
||||
self.ev.fuel = ev.fuel;
|
||||
Some(v)
|
||||
}
|
||||
fn has_lambda(&self) -> bool {
|
||||
self.lambda.is_some()
|
||||
@@ -147,7 +198,7 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
|
||||
}
|
||||
|
||||
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
||||
@@ -233,10 +284,18 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
pub fn eval(&mut self, e: &Expr) -> Ev {
|
||||
let here = stack_position();
|
||||
if self.depth == 0 {
|
||||
self.stack_top = self.host.stack_top().unwrap_or(here);
|
||||
}
|
||||
if self.stack_top.abs_diff(here) > STACK_BUDGET {
|
||||
self.host.stack_exhausted();
|
||||
return err(CellError::Num);
|
||||
}
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
self.depth -= 1;
|
||||
return err(CellError::Calc);
|
||||
return err(CellError::Num);
|
||||
}
|
||||
let r = self.eval_inner(e);
|
||||
self.depth -= 1;
|
||||
@@ -520,7 +579,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
_ => {
|
||||
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
|
||||
// of one level of recursion per operator (past the depth limit it was #CALC!).
|
||||
// of one level of recursion per operator (past the depth limit it was an error).
|
||||
let mut rest = vec![(op, b)];
|
||||
let mut left = a;
|
||||
while let Expr::Binary(o, l, r) = left
|
||||
@@ -829,10 +888,11 @@ impl<'h> Evaluator<'h> {
|
||||
fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
let Some(spec) = gridcraft_functions::lookup(name) else {
|
||||
// A defined name holding a LAMBDA, or a LET-bound lambda.
|
||||
if let Ev::L(l) = self.name(name) {
|
||||
return self.apply_lambda(&l, args);
|
||||
}
|
||||
return err(CellError::Name);
|
||||
return match self.name(name) {
|
||||
Ev::L(l) => self.apply_lambda(&l, args),
|
||||
Ev::V(Value::Error(x)) => err(x),
|
||||
_ => err(CellError::Name),
|
||||
};
|
||||
};
|
||||
if args.len() < spec.min_args || args.len() > spec.max_args {
|
||||
return err(CellError::Value);
|
||||
@@ -983,10 +1043,22 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
||||
if self.calls >= MAX_LAMBDA_CALLS {
|
||||
return err(CellError::Num);
|
||||
}
|
||||
// #2382: one charge per invocation, so a cell's total work stays bounded however the
|
||||
// lambdas nest. Foregrounding the charge here means every path into a body (direct
|
||||
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
|
||||
self.fuel = match self.fuel.checked_sub(1) {
|
||||
Some(f) => f,
|
||||
None => return err(CellError::Calc),
|
||||
};
|
||||
self.calls += 1;
|
||||
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
||||
self.env.extend(bound);
|
||||
let r = self.eval(&l.body);
|
||||
self.env = saved;
|
||||
self.calls -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -1301,7 +1373,13 @@ impl<'h> Evaluator<'h> {
|
||||
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
|
||||
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
|
||||
let r = a.range;
|
||||
// A single row or column with one index picks along it.
|
||||
// A single row or column with one index picks along it. An area with several rows
|
||||
// and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third
|
||||
// argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ:
|
||||
// `INDEX({1,2;3,4},2)` is the row.
|
||||
if two_args && r.height() > 1 && r.width() > 1 {
|
||||
return err(CellError::Ref);
|
||||
}
|
||||
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
|
||||
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
|
||||
return err(CellError::Ref);
|
||||
@@ -1978,3 +2056,54 @@ pub fn is_known_function(n: &str) -> bool {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: RefKind) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fuel_tests {
|
||||
use super::*;
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
struct Empty {
|
||||
wb: Workbook,
|
||||
}
|
||||
impl Host for Empty {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
&self.wb
|
||||
}
|
||||
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
|
||||
Value::Empty
|
||||
}
|
||||
fn now_serial(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn random(&mut self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_with(fuel: u64, src: &str) -> Value {
|
||||
let mut host = Empty { wb: Workbook::default() };
|
||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
||||
ev.fuel = fuel;
|
||||
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_fuel_and_run_out_quietly() {
|
||||
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
|
||||
// here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape,
|
||||
// which happens well before the budget does.)
|
||||
let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||
assert_eq!(r, Value::number(4.0), "{r:?}");
|
||||
// One charge short: the nest bottoms out in #CALC! instead of running unbounded.
|
||||
let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||
// One charge short of a single application: #CALC! with a zero budget.
|
||||
let r = eval_with(0, "=LAMBDA(x,x+1)(2)");
|
||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||
let r = eval_with(1, "=LAMBDA(x,x+1)(2)");
|
||||
assert_eq!(r, Value::number(3.0));
|
||||
// A flat helper map keeps working (3 of 3 invokes inside the budget).
|
||||
let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))");
|
||||
assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+140
-2
@@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{CalcMode, Workbook};
|
||||
|
||||
use crate::eval::{Area, Evaluator, Host, precedents};
|
||||
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
|
||||
|
||||
pub type Key = (usize, CellRef);
|
||||
|
||||
@@ -432,6 +432,10 @@ struct PassHost<'a> {
|
||||
tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
/// `stack_position()` where the outermost formula being calculated started.
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
@@ -462,6 +466,8 @@ impl<'a> PassHost<'a> {
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,6 +586,9 @@ impl PassHost<'_> {
|
||||
self.suspend = Some(k);
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.stack_top = stack_position();
|
||||
}
|
||||
self.in_progress.insert(k);
|
||||
self.depth += 1;
|
||||
let outer_taint = std::mem::replace(&mut self.taint, false);
|
||||
@@ -587,10 +596,24 @@ impl PassHost<'_> {
|
||||
self.stack.push(k);
|
||||
}
|
||||
let expr = std::sync::Arc::clone(expr);
|
||||
let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false);
|
||||
let v = {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
ev.value(&expr)
|
||||
};
|
||||
let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack);
|
||||
if out_of_stack && self.depth > 1 && self.suspend.is_none() {
|
||||
// It ran out of the stack left below the formulas that read it: it is calculated
|
||||
// from the top instead, as a formula needed at `MAX_NESTED` levels deep is.
|
||||
self.suspend = Some(k);
|
||||
self.depth -= 1;
|
||||
self.in_progress.remove(&k);
|
||||
if self.iterate {
|
||||
self.stack.pop();
|
||||
}
|
||||
self.taint = outer_taint;
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
if self.suspend.is_some() {
|
||||
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
|
||||
self.depth -= 1;
|
||||
@@ -745,6 +768,12 @@ impl Host for PassHost<'_> {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
self.wb
|
||||
}
|
||||
fn stack_top(&self) -> Option<usize> {
|
||||
(self.depth > 1).then_some(self.stack_top)
|
||||
}
|
||||
fn stack_exhausted(&mut self) {
|
||||
self.out_of_stack = true;
|
||||
}
|
||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||
let k = (sheet, anchor);
|
||||
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
|
||||
@@ -913,6 +942,7 @@ impl Calc {
|
||||
/// Rebuilds the graph from scratch.
|
||||
pub fn rebuild(&mut self, wb: &Workbook) {
|
||||
self.graph = Graph::default();
|
||||
let mut circ = Vec::new();
|
||||
for (si, sheet) in wb.sheets.iter().enumerate() {
|
||||
for (c, cell) in sheet.cells.iter() {
|
||||
if let Some(f) = &cell.formula
|
||||
@@ -920,10 +950,106 @@ impl Calc {
|
||||
{
|
||||
let (areas, dynamic) = precedents(wb, si, &e);
|
||||
self.graph.insert((si, c), Node { areas, dynamic });
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.graph.settle();
|
||||
self.circular = if wb.calc.iterative { Vec::new() } else { self.loops_among(&circ) };
|
||||
}
|
||||
|
||||
/// The circular references of a rebuilt workbook (after undo, redo or a rollback, which put
|
||||
/// back cells without calculating them), from the formulas whose cached value is `#CIRC!`:
|
||||
/// the listed cells still on a loop, and one cell of each loop none of them is on.
|
||||
fn loops_among(&self, circ: &[Key]) -> Vec<Key> {
|
||||
if circ.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let index: HashMap<Key, usize> = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect();
|
||||
// Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way.
|
||||
let mut deps = Vec::new();
|
||||
let edges: Vec<Vec<usize>> = circ
|
||||
.iter()
|
||||
.map(|k| {
|
||||
deps.clear();
|
||||
self.graph.dependents(k.0, k.1, &mut deps);
|
||||
let mut e: Vec<usize> = deps.iter().filter_map(|d| index.get(d).copied()).collect();
|
||||
e.sort_unstable();
|
||||
e.dedup();
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
// Tarjan's strongly connected components, iterative.
|
||||
let n = circ.len();
|
||||
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
|
||||
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
|
||||
let (mut next, mut comps) = (0usize, 0usize);
|
||||
for root in 0..n {
|
||||
if num[root] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
num[root] = next;
|
||||
low[root] = next;
|
||||
next += 1;
|
||||
stack.push(root);
|
||||
on_stack[root] = true;
|
||||
while let Some(top) = call.last_mut() {
|
||||
let v = top.0;
|
||||
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
|
||||
top.1 += 1;
|
||||
if num[w] == usize::MAX {
|
||||
num[w] = next;
|
||||
low[w] = next;
|
||||
next += 1;
|
||||
stack.push(w);
|
||||
on_stack[w] = true;
|
||||
call.push((w, 0));
|
||||
} else if on_stack[w] {
|
||||
low[v] = low[v].min(num[w]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
call.pop();
|
||||
if let Some(&(u, _)) = call.last() {
|
||||
low[u] = low[u].min(low[v]);
|
||||
}
|
||||
if low[v] == num[v] {
|
||||
while let Some(w) = stack.pop() {
|
||||
on_stack[w] = false;
|
||||
comp[w] = comps;
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
comps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut size = vec![0usize; comps];
|
||||
for &c in &comp {
|
||||
if let Some(s) = size.get_mut(c) {
|
||||
*s += 1;
|
||||
}
|
||||
}
|
||||
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
|
||||
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
|
||||
let mut out: Vec<Key> =
|
||||
self.circular.iter().copied().filter(|k| index.get(k).is_some_and(|&i| on_loop(i) || self.graph.dynamic.contains(k))).collect();
|
||||
let mut covered: HashSet<usize> = out.iter().filter_map(|k| index.get(k)).filter_map(|&i| comp.get(i).copied()).collect();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| circ.get(i).map(|(s, c)| (*s, c.row, c.col)));
|
||||
for i in order {
|
||||
if let (Some(&c), Some(&k)) = (comp.get(i), circ.get(i))
|
||||
&& on_loop(i)
|
||||
&& covered.insert(c)
|
||||
{
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
|
||||
@@ -1103,7 +1229,12 @@ impl Calc {
|
||||
}
|
||||
let tw = prof_now();
|
||||
self.last_recalc_cells += results.len();
|
||||
self.circular = cycles;
|
||||
// The loops this round finds replace the ones listed for the formulas it evaluated.
|
||||
if !self.circular.is_empty() {
|
||||
let evaluated: HashSet<Key> = dirty.iter().copied().collect();
|
||||
self.circular.retain(|k| !evaluated.contains(k));
|
||||
}
|
||||
self.circular.extend(cycles);
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = Vec::new();
|
||||
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
|
||||
@@ -1216,6 +1347,13 @@ impl Calc {
|
||||
}
|
||||
dirty = self.dirty_closure(wb, &spill_changes);
|
||||
}
|
||||
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
|
||||
// Iterative calculation reports none.
|
||||
if wb.calc.iterative {
|
||||
self.circular.clear();
|
||||
} else {
|
||||
self.circular.retain(|k| self.graph.nodes.contains_key(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,81 @@ fn index_area_number_overflow() {
|
||||
}
|
||||
}
|
||||
|
||||
/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a
|
||||
/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value.
|
||||
#[test]
|
||||
fn index_with_one_index() {
|
||||
let mut t = T::new();
|
||||
for (i, v) in [10, 20, 30].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), &v.to_string());
|
||||
}
|
||||
let mut k = 1;
|
||||
for r in 5..=7 {
|
||||
for col in ["A", "B", "C"] {
|
||||
t.set(&format!("{col}{r}"), &k.to_string());
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
for (row, (formula, expected)) in [
|
||||
// Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value.
|
||||
("=INDEX({10;20;30},2)", 20.0),
|
||||
("=TYPE(INDEX({10;20;30},2))", 64.0),
|
||||
("=TYPE(INDEX(SEQUENCE(3),2))", 64.0),
|
||||
("=TYPE(INDEX({10;20;30},2,1))", 1.0),
|
||||
("=TYPE(INDEX({10,20,30},2))", 1.0),
|
||||
("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0),
|
||||
("=SUM(INDEX({10;20;30},2))", 20.0),
|
||||
// 1-D references with one index are single-cell references.
|
||||
("=TYPE(INDEX(A1:A3,2))", 1.0),
|
||||
("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0),
|
||||
("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0),
|
||||
// A 2-D array with one index is the row.
|
||||
("=SUM(INDEX({1,2;3,4},2))", 7.0),
|
||||
("=SUM(INDEX({1,2;3,4},0))", 10.0),
|
||||
// A 2-D reference with an empty (not omitted) column argument is the row.
|
||||
("=SUM(INDEX(A5:C7,2,))", 15.0),
|
||||
("=SUM(INDEX(A5:C7,2,,1))", 15.0),
|
||||
("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0),
|
||||
("=INDEX(A5:C7,2,2)", 5.0),
|
||||
// A 2-D reference with one index is #REF!, and the error carries through.
|
||||
("=TYPE(INDEX(A5:C7,2))", 16.0),
|
||||
("=ISREF(INDEX(A5:C7,2))", 0.0),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = format!("J{}", row * 4 + 1);
|
||||
t.set(&at, formula);
|
||||
let got = match t.get(&at) {
|
||||
Value::Bool(b) => f64::from(u8::from(b)),
|
||||
v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")),
|
||||
};
|
||||
assert_eq!(got, *expected, "{formula}");
|
||||
}
|
||||
for formula in [
|
||||
"=INDEX(A5:C7,2)",
|
||||
"=INDEX(A5:C7,0)",
|
||||
"=INDEX(A5:C7,1.9)",
|
||||
"=INDEX(A5:C7,)",
|
||||
"=INDEX(A5:B6,1)",
|
||||
"=INDEX(A5:C7,2)+1",
|
||||
"=SUM(INDEX(A5:C7,{1;2}))",
|
||||
"=ROWS(INDEX(INDEX(A5:C7,2),3))",
|
||||
"=SUM(A5:INDEX(A5:C7,2))",
|
||||
"=SUM(INDEX(A5:C7,2):C7)",
|
||||
] {
|
||||
t.set("L1", formula);
|
||||
assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}");
|
||||
}
|
||||
t.set("L1", "=INDEX(A5:C7,{1;2})");
|
||||
assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]);
|
||||
t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")");
|
||||
assert_eq!(t.get("L1"), Value::from("e"));
|
||||
// A bad position is still #VALUE!.
|
||||
t.set("L1", "=INDEX(A5:C7,\"x\")");
|
||||
assert_eq!(t.get("L1"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_cycles() {
|
||||
let mut t = T::new();
|
||||
@@ -309,6 +384,32 @@ fn circular_references_without_iterative_calculation() {
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
}
|
||||
|
||||
/// The list of circular references keeps the loops that a recalculation of other cells does not
|
||||
/// reach, and drops a loop once a constant is typed over one of its cells.
|
||||
#[test]
|
||||
fn circular_references_stay_listed_until_the_loop_is_removed() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=A1+1");
|
||||
t.set("B1", "=B2");
|
||||
t.set("B2", "=B1");
|
||||
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
||||
t.set("D4", "7");
|
||||
t.set("D5", "=D4*2");
|
||||
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
||||
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
|
||||
t.set("A1", "5");
|
||||
assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
||||
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
|
||||
t.set("B2", "3");
|
||||
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
|
||||
// In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it.
|
||||
t.wb.calc.mode = gridcraft_model::CalcMode::Manual;
|
||||
t.set("C1", "=C1+1");
|
||||
assert_eq!(t.calc.circular, vec![(0, c("C1"))]);
|
||||
t.set("C1", "1");
|
||||
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_coerces_in_the_workbook_date_system() {
|
||||
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
|
||||
@@ -329,6 +430,149 @@ fn date_text_coerces_in_the_workbook_date_system() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
|
||||
/// significant digits. Every expectation was observed in Excel 365 with these fixtures
|
||||
/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
|
||||
#[test]
|
||||
fn lookups_compare_numbers_exactly() {
|
||||
let mut t = T::new();
|
||||
for (at, input) in [
|
||||
("A1", "=0.1+0.2"),
|
||||
("A2", "0.3"),
|
||||
("A3", "=100+1E-14"),
|
||||
("A4", "100"),
|
||||
("A5", "=1E15+0.5"),
|
||||
("A6", "1E15"),
|
||||
// Ascending 0.1, 0.3+e, 0.5 and the same descending.
|
||||
("B1", "0.1"),
|
||||
("B2", "=0.1+0.2"),
|
||||
("B3", "0.5"),
|
||||
("C1", "0.5"),
|
||||
("C2", "=0.1+0.2"),
|
||||
("C3", "0.1"),
|
||||
// 0.3 next to 0.3+e, ascending and descending.
|
||||
("D1", "0.3"),
|
||||
("D2", "=0.1+0.2"),
|
||||
("D3", "0.7"),
|
||||
("E1", "0.7"),
|
||||
("E2", "=0.1+0.2"),
|
||||
("E3", "0.3"),
|
||||
("G1", "=0.1+0.2"),
|
||||
("H1", "x"),
|
||||
("G2", "row2"),
|
||||
// The bisection probes 0.3 first and must not run on into 0.3+e.
|
||||
("J1", "0.1"),
|
||||
("J2", "0.2"),
|
||||
("J3", "0.3"),
|
||||
("J4", "=0.1+0.2"),
|
||||
("J5", "0.4"),
|
||||
("K1", "a"),
|
||||
("K2", "b"),
|
||||
("K3", "c"),
|
||||
("K4", "d"),
|
||||
("K5", "e"),
|
||||
] {
|
||||
t.set(at, input);
|
||||
}
|
||||
let e = 0.1 + 0.2;
|
||||
let n = Value::Number;
|
||||
let s = |x: &str| Value::from(x);
|
||||
let na = Value::Error(CellError::NA);
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
// `=` and friends stay at 15 digits.
|
||||
("=A1=A2", Value::Bool(true)),
|
||||
("=A3=A4", Value::Bool(true)),
|
||||
("=A5=A6", Value::Bool(true)),
|
||||
("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
|
||||
// Exact match.
|
||||
("=MATCH(A1,A2:A2,0)", na.clone()),
|
||||
("=MATCH(A2,A1:A1,0)", na.clone()),
|
||||
("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
|
||||
("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
|
||||
("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
|
||||
("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
|
||||
("=XMATCH(A1,A2:A2)", na.clone()),
|
||||
("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
|
||||
("=MATCH(0.3,A1:A2,0)", n(2.0)),
|
||||
("=MATCH(A1,A1:A2,0)", n(1.0)),
|
||||
("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
|
||||
("=MATCH(100,A3:A3,0)", na.clone()),
|
||||
("=MATCH(1E15,A5:A5,0)", na.clone()),
|
||||
("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
|
||||
("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
|
||||
// Approximate match against a single entry.
|
||||
("=MATCH(0.3,A1:A1,1)", na.clone()),
|
||||
("=MATCH(0.3,A1:A1,-1)", n(1.0)),
|
||||
("=LOOKUP(0.3,A1:A1)", na.clone()),
|
||||
("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
|
||||
("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
|
||||
("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
|
||||
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
|
||||
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
|
||||
("=XMATCH(0.3,A1:A1,-1)", na.clone()),
|
||||
("=XMATCH(0.3,A1:A1,1)", n(1.0)),
|
||||
("=MATCH(100,A3:A3,1)", na.clone()),
|
||||
("=MATCH(A3,A4:A4,1)", n(1.0)),
|
||||
// Approximate match in sorted lists.
|
||||
("=MATCH(0.3,B1:B3,1)", n(1.0)),
|
||||
("=MATCH(A1,B1:B3,1)", n(2.0)),
|
||||
("=MATCH(0.3,C1:C3,-1)", n(2.0)),
|
||||
("=MATCH(0.3,D1:D3,1)", n(1.0)),
|
||||
("=MATCH(A1,D1:D3,1)", n(2.0)),
|
||||
("=MATCH(0.3,E1:E3,-1)", n(3.0)),
|
||||
("=MATCH(A1,E1:E3,-1)", n(2.0)),
|
||||
("=MATCH(0.3,J1:J5,1)", n(3.0)),
|
||||
("=MATCH(A1,J1:J5,1)", n(4.0)),
|
||||
("=MATCH(0.3,J1:J5,0)", n(3.0)),
|
||||
("=MATCH(A1,J1:J5,0)", n(4.0)),
|
||||
("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
|
||||
("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
|
||||
("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
|
||||
("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
|
||||
// XLOOKUP / XMATCH match and search modes.
|
||||
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
|
||||
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
|
||||
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
|
||||
("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
|
||||
("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
|
||||
("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
|
||||
("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
|
||||
("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
|
||||
("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
|
||||
("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
|
||||
("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
|
||||
("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
|
||||
("=XMATCH(0.3,J1:J5,1)", n(3.0)),
|
||||
("=XMATCH(A1,J1:J5,-1)", n(4.0)),
|
||||
("=XMATCH(0.3,B1:B3,1)", n(2.0)),
|
||||
("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
|
||||
("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
|
||||
("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
|
||||
// The criteria functions keep 15 digits.
|
||||
("=COUNTIF(A1:A1,0.3)", n(1.0)),
|
||||
("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
|
||||
("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
|
||||
("=COUNTIF(A2:A2,A1)", n(1.0)),
|
||||
("=COUNTIFS(A1:A1,0.3)", n(1.0)),
|
||||
("=SUMIF(A1:A1,0.3)", n(e)),
|
||||
("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
|
||||
("=AVERAGEIF(A1:A1,0.3)", n(e)),
|
||||
("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
|
||||
("=COUNTIF(A3:A3,\">100\")", n(0.0)),
|
||||
("=COUNTIF(J1:J5,0.3)", n(2.0)),
|
||||
("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
|
||||
("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
|
||||
];
|
||||
let mut wrong = Vec::new();
|
||||
for (formula, expected) in cases {
|
||||
let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
|
||||
if got != expected {
|
||||
wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_helpers_check_shapes_and_parameters() {
|
||||
let mut t = T::new();
|
||||
@@ -352,6 +596,39 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_one_work_budget_per_cell() {
|
||||
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
|
||||
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
|
||||
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
|
||||
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
|
||||
let mut t = T::new();
|
||||
t.set(
|
||||
"A1",
|
||||
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
|
||||
);
|
||||
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
|
||||
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
|
||||
assert_eq!(t.num("A1"), 45.0);
|
||||
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
|
||||
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "'$(1,234)");
|
||||
t.set("B1", "=A1+0");
|
||||
t.set("C1", "=VALUE(A1)");
|
||||
t.set("D1", "=--\"$ (5)\"");
|
||||
t.set("E1", "=\"(5)%\"*100");
|
||||
t.set("F1", "=\"(-5)\"+0");
|
||||
t.set("G1", "$(1,234)");
|
||||
assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0));
|
||||
assert_eq!(t.get("F1"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("G1"), -1234.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
@@ -774,6 +1051,100 @@ fn lambda_argument_count() {
|
||||
assert_eq!(t.get("A9"), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
|
||||
// 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let mut t = T::new();
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "LOOP".into(),
|
||||
scope: None,
|
||||
formula: "LAMBDA(n,LOOP(n))".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.calc.rebuild(&t.wb);
|
||||
let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
|
||||
t.set("A1", endless);
|
||||
t.set("A2", "=LOOP(1)");
|
||||
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
|
||||
t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
|
||||
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
|
||||
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
|
||||
})
|
||||
.expect("spawn");
|
||||
let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
|
||||
assert_eq!(endless, Value::Error(CellError::Num));
|
||||
assert_eq!(endless_name, Value::Error(CellError::Num));
|
||||
assert_eq!(through_map, Value::Error(CellError::Num));
|
||||
assert_eq!(evaluated, Value::Error(CellError::Num));
|
||||
assert_eq!(countdown, Value::Number(131.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
|
||||
let run = |stack: usize| {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(stack)
|
||||
.spawn(|| {
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "CNT".into(),
|
||||
scope: None,
|
||||
formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
// F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
|
||||
let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
|
||||
let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
|
||||
[tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("recursion must not overflow the stack")
|
||||
};
|
||||
let small = run(1 << 20);
|
||||
assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
|
||||
assert_eq!(run(64 << 20), small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() {
|
||||
let run = |stack: usize| {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(stack)
|
||||
.spawn(|| {
|
||||
// Row-major order calculates A1 first, so each cell below is calculated on demand
|
||||
// at the deepest call of the one above it.
|
||||
let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.sheet_mut(0).expect("sheet");
|
||||
for (at, f) in [
|
||||
("A1", countdown("A2")),
|
||||
("A2", countdown("A3")),
|
||||
("A3", countdown("0")),
|
||||
("B1", countdown("B2")),
|
||||
("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()),
|
||||
] {
|
||||
sheet.cells.set(c(at), Cell::formula(Formula::new(&f)));
|
||||
}
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default())
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("recursion must not overflow the stack")
|
||||
};
|
||||
let small = run(1 << 20);
|
||||
let num = Value::Error(CellError::Num);
|
||||
assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]);
|
||||
assert_eq!(run(64 << 20), small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_sheets() {
|
||||
let mut t = T::new();
|
||||
@@ -1513,6 +1884,91 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_toolpak_value_params_reject_multi_cell_references() {
|
||||
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
|
||||
// than one cell, however the reference is formed, and lift array constants and computed arrays.
|
||||
let mut t = T::new();
|
||||
for (a, v) in [
|
||||
("A1", "45000"),
|
||||
("A2", "45031"),
|
||||
("A3", "45061"),
|
||||
("B1", "1"),
|
||||
("B2", "2"),
|
||||
("B3", "3"),
|
||||
("C2", "7"),
|
||||
("F1", "1"),
|
||||
("F2", "2"),
|
||||
("F3", "11"),
|
||||
] {
|
||||
t.set(a, v);
|
||||
}
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Dates".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$A$1:$A$3".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let value = Value::Error(CellError::Value);
|
||||
for f in [
|
||||
"=EOMONTH(A1:A3,1)",
|
||||
"=EOMONTH(A1,B1:B3)",
|
||||
"=EDATE(A1:A3,1)",
|
||||
"=SUMPRODUCT(EOMONTH(A1:A3,0))",
|
||||
"=EOMONTH(INDEX(A1:A3,0,1),0)",
|
||||
"=EOMONTH(OFFSET(A1,0,0,3,1),0)",
|
||||
"=EOMONTH(IF(TRUE,A1:A3),0)",
|
||||
"=EOMONTH(CHOOSE(1,A1:A3),0)",
|
||||
"=LET(x,A1:A3,EOMONTH(x,0))",
|
||||
"=EOMONTH(Dates,0)",
|
||||
"=EOMONTH((A1,A2),0)",
|
||||
"=EOMONTH(Z:Z,0)",
|
||||
"=EOMONTH(A1:INDEX(A1:A3,3),0)",
|
||||
"=WORKDAY(A1:A3,1)",
|
||||
"=NETWORKDAYS(A1,A3:A4)",
|
||||
"=YEARFRAC(A1,A3,B1:B2)",
|
||||
"=WEEKNUM(A1,B1:B3)",
|
||||
"=COUPDAYS(A1:A3,46000,2,0)",
|
||||
"=DISC(A1,46000,98,100,B1:B3)",
|
||||
"=BIN2DEC(B1:B3)",
|
||||
"=ISEVEN(B1:B3)",
|
||||
] {
|
||||
t.set("H5", f);
|
||||
assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}");
|
||||
t.set("H5", "");
|
||||
}
|
||||
// One-cell references, intersections, @ and the range parameters are fine.
|
||||
for (f, want) in [
|
||||
("=EOMONTH(A1:A1,0)", 45016.0),
|
||||
("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0),
|
||||
("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0),
|
||||
("=EOMONTH(A1:A3 A2:C2,0)", 45046.0),
|
||||
("=EOMONTH(@A1:A3,0)", 45046.0),
|
||||
("=WORKDAY(A1,1,A1:A3)", 45001.0),
|
||||
("=XNPV(0,B1:B3,A1:A3)", 6.0),
|
||||
] {
|
||||
t.set("H2", f);
|
||||
assert_eq!(t.num("H2"), want, "{f}");
|
||||
t.set("H2", "");
|
||||
}
|
||||
// Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend.
|
||||
for (f, below, right) in [
|
||||
("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty),
|
||||
("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)),
|
||||
("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)),
|
||||
("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty),
|
||||
("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty),
|
||||
("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty),
|
||||
// The rejected reference adds no shape: a 2x1 array of #VALUE!.
|
||||
("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty),
|
||||
] {
|
||||
t.set("H5", f);
|
||||
assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}");
|
||||
t.set("H5", "");
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
|
||||
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
|
||||
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
|
||||
@@ -1781,3 +2237,40 @@ fn text_before_after_empty_instance_defaults_to_one() {
|
||||
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
|
||||
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loan_schedule_residue_keeps_excels_sign() {
|
||||
// A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest,
|
||||
// each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment
|
||||
// Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes
|
||||
// from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "4653300");
|
||||
t.set("B2", "1");
|
||||
t.set("B3", "0.141");
|
||||
let col = |i: u8| char::from(b'A' + i).to_string();
|
||||
t.set("C8", "=$B$1*($B$3/12)");
|
||||
t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8");
|
||||
t.set("D8", "=($B1-C7)*($B$3/12)");
|
||||
t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8");
|
||||
for i in 4..16 {
|
||||
let (c, p) = (col(i), col(i - 1));
|
||||
t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")"));
|
||||
t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")"));
|
||||
}
|
||||
t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")");
|
||||
// Excel's values (Value2) for the same synthetic inputs.
|
||||
// The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference
|
||||
// moves it by as much as its own size: only its sign is checked, and the 13th period must exist.
|
||||
let positive = |cell: &str| match t.get(cell) {
|
||||
Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"),
|
||||
other => panic!("{cell}: expected a small positive number, got {other:?}"),
|
||||
};
|
||||
positive("N4");
|
||||
positive("O8");
|
||||
match t.get("O7") {
|
||||
Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"),
|
||||
other => panic!("O7: expected a number, got {other:?}"),
|
||||
}
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
@@ -160,21 +160,28 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
|
||||
segs.push((*a, *b));
|
||||
}
|
||||
if dash {
|
||||
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
|
||||
// long dashed lines multiplies: together they would eagerly allocate megabytes of
|
||||
// geometry even when nothing of it reaches the canvas, so the total is capped too and
|
||||
// the line is drawn solid once the budget is gone.
|
||||
let (on, off) = (width * 4.0, width * 3.0);
|
||||
let mut out = Vec::new();
|
||||
let mut budget = 65_536usize;
|
||||
for (a, b) in segs {
|
||||
if budget == 0 {
|
||||
return vec![vec![pts.to_vec()]];
|
||||
}
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if !(len.is_finite() && len > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let mut t = 0.0;
|
||||
let mut n = 0;
|
||||
while t < len && n < 10_000 {
|
||||
while t < len && budget > 0 {
|
||||
let e = (t + on).min(len);
|
||||
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
||||
t += on + off;
|
||||
n += 1;
|
||||
budget -= 1;
|
||||
}
|
||||
}
|
||||
segs = out;
|
||||
@@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
||||
}
|
||||
cv.buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dashed_geometry_stays_within_the_dash_budget() {
|
||||
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
|
||||
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
|
||||
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
|
||||
let prims: Vec<Prim> =
|
||||
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
|
||||
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
|
||||
assert_eq!(img.len(), 2 * 2 * 4);
|
||||
// And the geometry itself is bounded even for one huge dashed polyline.
|
||||
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
|
||||
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
|
||||
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-15
@@ -425,7 +425,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||
if !horizontal {
|
||||
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
|
||||
let y2labels = y2.map(|a| a.labels());
|
||||
let y2labels = y2.map(ValAxis::labels);
|
||||
let right = match (&y2labels, &cat) {
|
||||
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
|
||||
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
||||
@@ -868,6 +868,7 @@ enum Fam {
|
||||
LineStack,
|
||||
Area,
|
||||
AreaStack,
|
||||
Area100,
|
||||
}
|
||||
|
||||
fn family(k: ChartKind) -> Fam {
|
||||
@@ -879,10 +880,16 @@ fn family(k: ChartKind) -> Fam {
|
||||
LineStacked => Fam::LineStack,
|
||||
Area => Fam::Area,
|
||||
AreaStacked => Fam::AreaStack,
|
||||
AreaStacked100 => Fam::Area100,
|
||||
_ => Fam::Col,
|
||||
}
|
||||
}
|
||||
|
||||
/// Families stacked to 100%: each category's values are shares of its total.
|
||||
fn is_100(f: Fam) -> bool {
|
||||
matches!(f, Fam::Col100 | Fam::Area100)
|
||||
}
|
||||
|
||||
fn is_col(f: Fam) -> bool {
|
||||
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
|
||||
}
|
||||
@@ -900,25 +907,38 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
||||
|
||||
// Totals for 100% stacking, per axis.
|
||||
let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]];
|
||||
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
|
||||
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
if fams.get(i) == Some(&Fam::Col100) {
|
||||
let a = sec.get(i).copied().unwrap_or(false) as usize;
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
if is_100(fam) {
|
||||
let key = (sec.get(i).copied().unwrap_or(false), fam);
|
||||
let p = match totals.iter().position(|(k, _)| *k == key) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
totals.push((key, vec![0.0; ncat]));
|
||||
totals.len() - 1
|
||||
}
|
||||
};
|
||||
for (j, v) in s.values.iter().take(ncat).enumerate() {
|
||||
if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) {
|
||||
if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) {
|
||||
*t += v.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A value's share of its category's total, for the 100% families.
|
||||
let share = |key: (bool, Fam), j: usize, v: f64| {
|
||||
let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0);
|
||||
if t > 0.0 { v / t } else { 0.0 }
|
||||
};
|
||||
// Stack accumulators keyed by (axis, family): positive and negative running sums.
|
||||
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
|
||||
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
let a = sec.get(i).copied().unwrap_or(false);
|
||||
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack);
|
||||
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);
|
||||
let key = (a, fam);
|
||||
let si = if stacked {
|
||||
match stacks.iter().position(|(k, _, _)| *k == key) {
|
||||
@@ -936,12 +956,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
let v = s.values.get(j).copied().flatten();
|
||||
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
|
||||
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
|
||||
let v = if fam == Fam::Col100 {
|
||||
let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0);
|
||||
if t > 0.0 { v / t } else { 0.0 }
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let v = if fam == Fam::Col100 { share(key, j, v) } else { v };
|
||||
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
|
||||
let base = *acc;
|
||||
*acc += v;
|
||||
@@ -949,6 +964,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
}),
|
||||
(Some((_, pos, _)), _) => {
|
||||
let v = v.unwrap_or(0.0);
|
||||
let v = if fam == Fam::Area100 { share(key, j, v) } else { v };
|
||||
pos.get_mut(j).map(|acc| {
|
||||
let base = *acc;
|
||||
*acc = clean(*acc + v);
|
||||
@@ -970,7 +986,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
continue;
|
||||
}
|
||||
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
|
||||
let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100));
|
||||
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
|
||||
let range = value_range(members.iter().flat_map(|i| {
|
||||
let line = fams.get(*i).is_some_and(|f| is_line(*f));
|
||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
|
||||
@@ -1044,7 +1060,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
let want = match pass {
|
||||
0 => matches!(fam, Fam::Area | Fam::AreaStack),
|
||||
0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100),
|
||||
1 => is_col(fam),
|
||||
_ => is_line(fam),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
||||
|
||||
use crate::*;
|
||||
|
||||
const ALL_KINDS: [ChartKind; 25] = [
|
||||
const ALL_KINDS: [ChartKind; 26] = [
|
||||
ChartKind::ColumnClustered,
|
||||
ChartKind::ColumnStacked,
|
||||
ChartKind::ColumnStacked100,
|
||||
@@ -19,6 +19,7 @@ const ALL_KINDS: [ChartKind; 25] = [
|
||||
ChartKind::Doughnut,
|
||||
ChartKind::Area,
|
||||
ChartKind::AreaStacked,
|
||||
ChartKind::AreaStacked100,
|
||||
ChartKind::Scatter,
|
||||
ChartKind::ScatterLines,
|
||||
ChartKind::Bubble,
|
||||
@@ -76,7 +77,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
||||
ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||
categories: cats.iter().map(ToString::to_string).collect(),
|
||||
series: vec![
|
||||
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
|
||||
b,
|
||||
@@ -1073,7 +1074,7 @@ fn pie_points_follow_workbook_theme() {
|
||||
|
||||
#[test]
|
||||
fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
|
||||
for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] {
|
||||
let d = sample(kind);
|
||||
let mut c = chart(kind);
|
||||
c.data_labels = false;
|
||||
@@ -1097,6 +1098,45 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100),
|
||||
series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100),
|
||||
],
|
||||
};
|
||||
let mut c = chart(ChartKind::AreaStacked100);
|
||||
c.data_labels = false;
|
||||
let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let t = texts(&p);
|
||||
assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}");
|
||||
assert!(!t.contains(&"40"), "{t:?}");
|
||||
let polys: Vec<&Vec<[f32; 2]>> = p
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Polygon { pts, .. } => Some(pts),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") };
|
||||
// Each polygon is its top edge left to right, then its base right to left.
|
||||
let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN);
|
||||
let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN);
|
||||
let (y0, y100) = (base(north, 0), top(south, 0));
|
||||
assert!(y0 > y100, "{y0} {y100}");
|
||||
let frac = |y: f32| (y0 - y) / (y0 - y100);
|
||||
for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] {
|
||||
assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j)));
|
||||
assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j)));
|
||||
}
|
||||
// A category whose total is zero stays on the baseline.
|
||||
assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_level_categories_resolve_and_draw() {
|
||||
let years = ["2023", "", "", "2024", "", ""];
|
||||
|
||||
+16
-1
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
|
||||
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
||||
chars.next();
|
||||
letters += 1;
|
||||
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
while chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,21 @@ mod tests {
|
||||
assert_eq!(split_sheet("A1"), (None, "A1"));
|
||||
}
|
||||
|
||||
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
|
||||
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
|
||||
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
|
||||
/// reference.
|
||||
#[test]
|
||||
fn split_sheet_leaves_device_names_inert_in_the_body() {
|
||||
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
|
||||
let (sheet, rest) = split_sheet(s);
|
||||
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
|
||||
let _ = sheet;
|
||||
}
|
||||
// A plain single-letter sheet is still split, not mistaken for a body.
|
||||
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_roundtrip() {
|
||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||
|
||||
+159
-23
@@ -54,23 +54,25 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
/// fractions kept as text are not flagged, as in Excel.
|
||||
pub fn number_stored_as_text(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n),
|
||||
// The same parentheses typed entry reads, accounting forms included: `$(5)`, `(5)%`.
|
||||
match parenthesized(t) {
|
||||
Some(inner) if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None,
|
||||
Some(inner) => parse_plain(&inner).map(|(n, _)| -n),
|
||||
None => parse_plain(t).map(|(n, _)| n),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
|
||||
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
|
||||
let mut t = t;
|
||||
let mut neg = false;
|
||||
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
t = inner.trim();
|
||||
neg = !neg;
|
||||
// Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`.
|
||||
if let Some(inner) = parenthesized(t) {
|
||||
// Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.
|
||||
if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {
|
||||
return None;
|
||||
}
|
||||
let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));
|
||||
return r.map(|(n, f)| (-n, f));
|
||||
}
|
||||
let (n, f) = parse_unparenthesised(t, sys)?;
|
||||
Some((if neg { -n } else { n }, f))
|
||||
parse_unparenthesised(t, sys)
|
||||
}
|
||||
|
||||
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
@@ -89,6 +91,22 @@ fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'stat
|
||||
None
|
||||
}
|
||||
|
||||
/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may
|
||||
/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as
|
||||
/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it.
|
||||
fn parenthesized(t: &str) -> Option<String> {
|
||||
let (currency, rest) = match t.strip_prefix('$') {
|
||||
Some(r) => ("$", r.trim_start()),
|
||||
None => ("", t),
|
||||
};
|
||||
let rest = rest.strip_prefix('(')?;
|
||||
let (body, percent) = match rest.strip_suffix(')') {
|
||||
Some(b) => (b, ""),
|
||||
None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"),
|
||||
};
|
||||
Some(format!("{currency}{}{percent}", body.trim()))
|
||||
}
|
||||
|
||||
/// Signs, currency, thousands separators, percent, exponent.
|
||||
fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
let mut s = t;
|
||||
@@ -113,7 +131,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
percent = true;
|
||||
s = r.trim_end();
|
||||
}
|
||||
if s.is_empty() {
|
||||
// Excel reads `$5` and `5%` but not `$5%`.
|
||||
if s.is_empty() || currency && percent {
|
||||
return None;
|
||||
}
|
||||
let has_sep = s.contains(',');
|
||||
@@ -352,10 +371,16 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
|
||||
if let Some((d, f)) = parse_date(t, sys) {
|
||||
return Some((d, Some(f)));
|
||||
}
|
||||
// date + time: split at the last space(s) before a time-looking tail.
|
||||
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
|
||||
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
|
||||
// instead of reparsing every prefix for every space (one long text field quadratic).
|
||||
for (i, _) in t.match_indices(' ') {
|
||||
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
|
||||
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
|
||||
let Some(b) = t.get(i + 1..) else { continue };
|
||||
let bytes = b.as_bytes();
|
||||
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
|
||||
return Some((d + tf, Some("m/d/yyyy h:mm")));
|
||||
}
|
||||
}
|
||||
@@ -381,6 +406,20 @@ mod tests {
|
||||
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_time_fields_stay_text() {
|
||||
// #2168: all-digit field lengths are gated before allocation/derivation,
|
||||
// so hostile strings never spin the time-derivation machinery.
|
||||
let big = "9".repeat(500);
|
||||
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
|
||||
assert_eq!(parse_number_text(&s), None, "{s}");
|
||||
}
|
||||
// Within the gate, times still work.
|
||||
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
|
||||
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
@@ -422,20 +461,107 @@ mod tests {
|
||||
assert_eq!(p(".5").value, Value::Number(0.5));
|
||||
}
|
||||
|
||||
/// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree.
|
||||
#[test]
|
||||
fn accounting_parentheses() {
|
||||
let accepted: [(&str, f64, Option<&str>); 22] = [
|
||||
("$(5)", -5.0, Some("\"$\"#,##0")),
|
||||
("$ (5)", -5.0, Some("\"$\"#,##0")),
|
||||
("$( 5 )", -5.0, Some("\"$\"#,##0")),
|
||||
("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")),
|
||||
("$(1,234)", -1234.0, Some("\"$\"#,##0")),
|
||||
("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")),
|
||||
("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")),
|
||||
("$(.5)", -0.5, Some("\"$\"#,##0.00")),
|
||||
("$(0)", 0.0, Some("\"$\"#,##0")),
|
||||
(" $(5) ", -5.0, Some("\"$\"#,##0")),
|
||||
("($5)", -5.0, Some("\"$\"#,##0")),
|
||||
("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")),
|
||||
("(5)", -5.0, None),
|
||||
("( 5 )", -5.0, None),
|
||||
("(1,234.5)", -1234.5, Some("#,##0.00")),
|
||||
("(1e-3)", -0.001, Some("0.00E+00")),
|
||||
("(5)%", -0.05, Some("0%")),
|
||||
("( 5 ) %", -0.05, Some("0%")),
|
||||
("(5%)", -0.05, Some("0%")),
|
||||
("(5.)%", -0.05, Some("0.00%")),
|
||||
("(1,234)%", -12.34, Some("0%")),
|
||||
("(1 1/2)", -1.5, Some("# ?/?")),
|
||||
];
|
||||
for (s, n, f) in accepted {
|
||||
assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}");
|
||||
assert_eq!(parse_number_text(s), Some(n), "{s}");
|
||||
}
|
||||
for s in [
|
||||
"$5%",
|
||||
"$(5%)",
|
||||
"$(5)%",
|
||||
"($5%)",
|
||||
"($5)%",
|
||||
"$ ( 5 ) %",
|
||||
"(5)%%",
|
||||
"(5%)%",
|
||||
"((5))",
|
||||
"(-5)",
|
||||
"(+5)",
|
||||
"($-5)",
|
||||
"($ -5)",
|
||||
"($+5)",
|
||||
"(-$5)",
|
||||
"$(-5)",
|
||||
"$ (-5)",
|
||||
"$(+5)",
|
||||
"-(5)",
|
||||
"-$(5)",
|
||||
"+$(5)",
|
||||
"$+(5)",
|
||||
"$$(5)",
|
||||
"$ $(5)",
|
||||
"$((5))",
|
||||
"($(5))",
|
||||
"(-1 1/2)",
|
||||
"()",
|
||||
"$()",
|
||||
"(5)$",
|
||||
"$(5)$",
|
||||
"(5)(5)",
|
||||
"$(5)0",
|
||||
"(5)e3",
|
||||
"(5)-",
|
||||
"(5-)",
|
||||
"$(5-)",
|
||||
"$(5",
|
||||
"$5)",
|
||||
"(5",
|
||||
"$(1,23)",
|
||||
"$(,5)",
|
||||
"(5e)",
|
||||
"(1/2)",
|
||||
"$(1/2)",
|
||||
"(3/4/2026)",
|
||||
"$(3/4/2026)",
|
||||
"(10:30)",
|
||||
"$(10:30)",
|
||||
] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
assert_eq!(parse_number_text(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_parentheses_do_not_overflow() {
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack. Excel reads
|
||||
// one pair only, so nesting is text now, and it is rejected without recursing.
|
||||
let depth = 100_000;
|
||||
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
|
||||
assert_eq!(parse_number_text(&s), Some(1.0));
|
||||
assert_eq!(parse_number_text(&s), None);
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()));
|
||||
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
|
||||
assert_eq!(parse_number_text(&odd), Some(-1.0));
|
||||
assert_eq!(parse_number_text(&odd), None);
|
||||
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
|
||||
assert_eq!(parse_number_text(&unbalanced), None);
|
||||
assert_eq!(p(&s).value, Value::Number(1.0));
|
||||
// Existing results are unchanged.
|
||||
// A single pair still reads.
|
||||
assert_eq!(parse_number_text("(100)"), Some(-100.0));
|
||||
assert_eq!(parse_number_text("((100))"), Some(100.0));
|
||||
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
|
||||
assert_eq!(parse_number_text("()"), None);
|
||||
}
|
||||
@@ -489,11 +615,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn numbers_stored_as_text() {
|
||||
for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] {
|
||||
for (text, n) in [
|
||||
("123", 123.0),
|
||||
(" 42 ", 42.0),
|
||||
("-1,000.5", -1000.5),
|
||||
("$3", 3.0),
|
||||
("50%", 0.5),
|
||||
("1E3", 1000.0),
|
||||
("(100)", -100.0),
|
||||
("$(5)", -5.0),
|
||||
("(5)%", -0.05),
|
||||
] {
|
||||
assert_eq!(number_stored_as_text(text), Some(n), "{text}");
|
||||
}
|
||||
// Dates, times, fractions, words and blanks are not flagged.
|
||||
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] {
|
||||
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))", "(-5)", "$(5)%", "(1/2)"] {
|
||||
assert_eq!(number_stored_as_text(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Value {
|
||||
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
|
||||
Value::Text(t) => Ok(t.to_string()),
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
|
||||
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
|
||||
}
|
||||
}
|
||||
/// Coercion to a boolean as `IF` does it.
|
||||
@@ -264,7 +264,7 @@ impl Value {
|
||||
}
|
||||
}
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
|
||||
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
|
||||
}
|
||||
}
|
||||
/// Display text with General formatting (for logs, CSV and tests).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, compare};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
|
||||
|
||||
/// The conditional look of one cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -267,6 +267,12 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so
|
||||
/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls
|
||||
/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic
|
||||
/// when a paste holds many bare `&`).
|
||||
const ENTITY_WINDOW: usize = 12;
|
||||
|
||||
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
|
||||
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
|
||||
fn decode(raw: &str) -> String {
|
||||
@@ -275,7 +281,9 @@ fn decode(raw: &str) -> String {
|
||||
while let Some(amp) = rest.find('&') {
|
||||
out.push_str(rest.get(..amp).unwrap_or_default());
|
||||
rest = rest.get(amp..).unwrap_or_default();
|
||||
let semi = rest.get(..12).unwrap_or(rest).find(';');
|
||||
// `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte
|
||||
// boundary used to fall back to the complete suffix, re-scanning per '&'.
|
||||
let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.find(';'));
|
||||
let entity = semi.and_then(|e| rest.get(1..e));
|
||||
let ch = entity.and_then(|e| match e {
|
||||
"amp" => Some('&'),
|
||||
@@ -382,6 +390,19 @@ mod tests {
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_recognized_only_within_the_twelve_byte_window() {
|
||||
// The window never scans the rest of the text for a semicolon: a bare '&' followed by
|
||||
// long plain text stays a bare '&', and one '&' after another is linear.
|
||||
assert_eq!(decode("a&lifier; b"), "a&lifier; b");
|
||||
assert_eq!(decode("&ampampampAMP;"), "&ampampampAMP;");
|
||||
// Inside the window the usual forms still decode, and a trailing bare '&' too.
|
||||
assert_eq!(decode("&"), "&");
|
||||
assert_eq!(decode("♪"), "\u{266A}");
|
||||
assert_eq!(decode("a&b c"), "a&b\u{a0}c");
|
||||
assert_eq!(decode("amp&"), "amp&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_markup_does_not_panic() {
|
||||
for html in [
|
||||
|
||||
@@ -106,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
show_levels
|
||||
),
|
||||
cmd!(
|
||||
"data.outlineSettings",
|
||||
"Outline Settings",
|
||||
["Data", "Outline"],
|
||||
None,
|
||||
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
|
||||
has_doc,
|
||||
outline_settings
|
||||
),
|
||||
cmd!(
|
||||
"data.subtotal",
|
||||
"Subtotal",
|
||||
@@ -1329,6 +1338,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let b = b.min(a.saturating_add(1_048_575));
|
||||
for i in a..=b {
|
||||
@@ -1339,9 +1349,10 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
// A summary line whose group is gone has nothing left to collapse.
|
||||
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
|
||||
if !on
|
||||
&& !outline::groups(map).iter().any(|g| g.end == b)
|
||||
&& let Some(e) = map.get_mut(&b.saturating_add(1))
|
||||
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
|
||||
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
|
||||
{
|
||||
e.collapsed = false;
|
||||
}
|
||||
@@ -1359,8 +1370,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let all = outline::groups(map);
|
||||
let all = outline::groups_with(map, before);
|
||||
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
|
||||
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
|
||||
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
|
||||
@@ -1369,13 +1381,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
};
|
||||
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
|
||||
if chosen.is_empty() {
|
||||
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
|
||||
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
|
||||
}
|
||||
for g in chosen {
|
||||
// Inner groups collapsed on their own (their summary marked), before this one changes.
|
||||
let inner: Vec<outline::OutlineGroup> = all
|
||||
.iter()
|
||||
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
|
||||
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
|
||||
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
|
||||
.copied()
|
||||
.collect();
|
||||
@@ -1384,6 +1396,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
|
||||
}
|
||||
}
|
||||
if !g.has_summary() {
|
||||
continue;
|
||||
}
|
||||
if hide {
|
||||
map.entry(g.summary()).or_default().collapsed = true;
|
||||
} else if let Some(e) = map.get_mut(&g.summary()) {
|
||||
@@ -1419,15 +1434,16 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
|
||||
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
|
||||
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
|
||||
let Some(n) = n else { continue };
|
||||
let groups = outline::groups(map);
|
||||
let groups = outline::groups_with(map, before);
|
||||
for e in map.values_mut().filter(|e| e.outline > 0) {
|
||||
set_hidden(e, e.outline >= n);
|
||||
}
|
||||
// Inner groups come first, so a summary line shared with the group around it ends up
|
||||
// as that outer group says.
|
||||
for g in groups {
|
||||
for g in groups.into_iter().filter(|g| g.has_summary()) {
|
||||
map.entry(g.summary()).or_default().collapsed = g.level >= n;
|
||||
}
|
||||
}
|
||||
@@ -1436,6 +1452,53 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
|
||||
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
|
||||
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
|
||||
let Some(on) = bool_param(p, key) else { continue };
|
||||
let was_before = outline::summary_before(sh, rows);
|
||||
if rows {
|
||||
sh.summary_below = on;
|
||||
} else {
|
||||
sh.summary_right = on;
|
||||
}
|
||||
if was_before != on {
|
||||
continue;
|
||||
}
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
|
||||
.into_iter()
|
||||
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
|
||||
.collect();
|
||||
// A mark on a line that nested groups share moves with the outermost of them whose
|
||||
// detail is all hidden, or with the outermost when none is.
|
||||
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
|
||||
for g in &marked {
|
||||
let key = (!g.collapsed, g.level);
|
||||
let best = owner.entry(g.summary()).or_insert(key);
|
||||
*best = (*best).min(key);
|
||||
}
|
||||
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
|
||||
for g in &marked {
|
||||
if let Some(e) = map.get_mut(&g.summary()) {
|
||||
e.collapsed = false;
|
||||
}
|
||||
}
|
||||
for g in marked {
|
||||
let g = outline::OutlineGroup { summary_before: !on, ..g };
|
||||
if g.has_summary() {
|
||||
map.entry(g.summary()).or_default().collapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
|
||||
})
|
||||
}
|
||||
|
||||
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
||||
let header = bool_param(p, "header").unwrap_or(true);
|
||||
@@ -1476,6 +1539,11 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
start = row;
|
||||
}
|
||||
}
|
||||
// The totals go below each group, so the outline puts summary rows below their detail (the
|
||||
// Subtotal dialog's "Summary below data"), in the same undo step.
|
||||
if !sh.summary_below {
|
||||
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
|
||||
}
|
||||
// Insert a total row after each group, bottom-up so indices stay valid.
|
||||
let mut inserted = 0;
|
||||
for (g0, g1, name) in groups.iter().rev() {
|
||||
@@ -1540,6 +1608,28 @@ mod tests {
|
||||
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_puts_summary_rows_below_their_detail() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.summary_below, "the totals sit below their groups");
|
||||
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
|
||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
||||
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
|
||||
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
|
||||
assert!(!hidden(&s, 3), "East Total stays visible");
|
||||
// One undo takes the subtotals and the setting back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.summary_below);
|
||||
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
@@ -1771,6 +1861,73 @@ mod tests {
|
||||
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
|
||||
let mut s = nested_rows();
|
||||
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
|
||||
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
|
||||
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
|
||||
// Hide Detail on the summary row collapses the group below it.
|
||||
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
|
||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
|
||||
// Ungrouping the outer group clears its mark on the row above.
|
||||
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_summary_direction_moves_collapsed_marks() {
|
||||
let mut s = nested_rows();
|
||||
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
|
||||
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
|
||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
|
||||
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
|
||||
assert!(col_marked(&s, 1), "columns keep their direction");
|
||||
// One undo step per change; no params leaves the settings as they are.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(row_state(&s, 3..=3)[0].1);
|
||||
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
|
||||
// Nested groups that end on one row share its mark: it moves to the outer group only.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
|
||||
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
|
||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
|
||||
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
|
||||
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
|
||||
// Hide and Show Detail keep it collapsed.
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
|
||||
// A group on the first row has no row above it to mark.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
|
||||
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_sort_rejects_row_key_zero_without_panicking() {
|
||||
let mut s = Session::new();
|
||||
|
||||
+285
-17
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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),
|
||||
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
|
||||
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
|
||||
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
|
||||
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
@@ -577,26 +577,154 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(json!({"selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
/// How many references the Go To dialog keeps for going back to.
|
||||
const GO_TO_RECENT: usize = 10;
|
||||
/// The most areas one Go To selects.
|
||||
const GO_TO_MAX_AREAS: usize = 2000;
|
||||
/// How deep a name that refers to other names is followed.
|
||||
const GO_TO_NAME_DEPTH: usize = 8;
|
||||
|
||||
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if bool_param(p, "list") == Some(true) {
|
||||
let d = s.doc()?;
|
||||
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent}));
|
||||
}
|
||||
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
||||
// A defined name or table?
|
||||
let d = s.doc()?;
|
||||
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
|
||||
let sheet = d.wb.active_sheet;
|
||||
if let Some(n) = d.wb.name(reference, sheet) {
|
||||
n.formula.clone()
|
||||
} else if let Some((si, ti)) = d.wb.table(reference) {
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
|
||||
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
|
||||
} else {
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas: Vec<RangeRef> = Vec::new();
|
||||
let mut named = false;
|
||||
for part in reference_parts(reference) {
|
||||
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
};
|
||||
// One selection holds the areas of one sheet.
|
||||
if target.is_some_and(|t| t != sheet) {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
}
|
||||
} else {
|
||||
reference.to_string()
|
||||
target = Some(sheet);
|
||||
named |= by_name;
|
||||
areas.extend(found);
|
||||
if areas.len() > GO_TO_MAX_AREAS {
|
||||
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
|
||||
}
|
||||
}
|
||||
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
|
||||
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
|
||||
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
|
||||
if bool_param(p, "record") == Some(true) && !named {
|
||||
let d = s.doc_mut()?;
|
||||
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
|
||||
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
|
||||
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
|
||||
d.go_to_recent.insert(0, entry);
|
||||
d.go_to_recent.truncate(GO_TO_RECENT);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
|
||||
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
|
||||
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
|
||||
fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
|
||||
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
|
||||
let mut chars = text.char_indices();
|
||||
std::iter::from_fn(move || {
|
||||
if done {
|
||||
return None;
|
||||
}
|
||||
for (i, c) in chars.by_ref() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
'(' if !quoted => depth += 1,
|
||||
')' if !quoted => depth = depth.saturating_sub(1),
|
||||
',' if !quoted && depth == 0 => {
|
||||
let part = text.get(start..i).unwrap_or("").trim();
|
||||
start = i + 1;
|
||||
return Some(part);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
done = true;
|
||||
Some(text.get(start..).unwrap_or("").trim())
|
||||
})
|
||||
}
|
||||
|
||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
|
||||
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
|
||||
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
|
||||
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
|
||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let (prefix, body) = split_sheet(part);
|
||||
let on = match &prefix {
|
||||
Some(n) => wb.sheet_index(n)?,
|
||||
None => sheet,
|
||||
};
|
||||
let resolved = resolved.replace('$', "");
|
||||
selection_set(s, &json!({"range": resolved}))
|
||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
||||
return Some((on, vec![r], false));
|
||||
}
|
||||
let name = match prefix {
|
||||
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
|
||||
None => wb.name(body, sheet),
|
||||
};
|
||||
if let Some(n) = name {
|
||||
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
|
||||
}
|
||||
if prefix.is_none()
|
||||
&& let Some((si, ti)) = wb.table(body)
|
||||
{
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
||||
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
|
||||
/// reference.
|
||||
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
|
||||
let from = n.scope.unwrap_or(sheet);
|
||||
let formula = n.formula.trim().trim_start_matches('=');
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas = Vec::new();
|
||||
for p in reference_parts(formula) {
|
||||
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
|
||||
if target.is_some_and(|t| t != s) {
|
||||
return None;
|
||||
}
|
||||
target = Some(s);
|
||||
areas.extend(found);
|
||||
if areas.len() > GO_TO_MAX_AREAS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
target.map(|t| (t, areas))
|
||||
}
|
||||
|
||||
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
|
||||
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
|
||||
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
|
||||
/// do not refer to a range are left out.
|
||||
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
|
||||
let own: std::collections::HashSet<String> =
|
||||
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
||||
let mut out: Vec<String> = wb
|
||||
.names
|
||||
.iter()
|
||||
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
|
||||
.filter_map(|n| match n.scope {
|
||||
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
|
||||
None => Some(n.name.clone()),
|
||||
Some(s) if s == sheet => Some(n.name.clone()),
|
||||
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
|
||||
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
|
||||
out
|
||||
}
|
||||
|
||||
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -741,7 +869,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
// scripts get it only when they ask for it.
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
|
||||
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust };
|
||||
s.clipboard = Some(clip);
|
||||
let mut result = json!({"range": range.a1(), "text": text});
|
||||
if let Some(html) = html {
|
||||
@@ -1264,6 +1392,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
|
||||
Ok(())
|
||||
})?;
|
||||
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
|
||||
// formats (cell styles and merged areas, no conditional formats), column widths and notes
|
||||
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
|
||||
// except for column widths and notes, which write no cells at all.
|
||||
let passive =
|
||||
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
|
||||
if !same_doc && !passive {
|
||||
let d = s.doc_mut()?;
|
||||
d.trust = d.trust.receive(clip.trust);
|
||||
}
|
||||
if cut_move {
|
||||
s.clipboard = None;
|
||||
}
|
||||
@@ -1452,6 +1590,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
"extend" => crate::fill::FillMode::Extend,
|
||||
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
|
||||
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
|
||||
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
|
||||
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
||||
@@ -1776,6 +1918,132 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
|
||||
/// formula and a hidden name.
|
||||
fn go_to_workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
|
||||
s.execute("selection.set", json!({"sheet": 0})).unwrap();
|
||||
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
||||
};
|
||||
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
|
||||
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
|
||||
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
|
||||
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
|
||||
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
|
||||
define(&mut s, "Rate", "=0.07", "Workbook");
|
||||
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Secret".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$Z$9".into(),
|
||||
comment: String::new(),
|
||||
hidden: true,
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
|
||||
/// that are not ranges, and hidden names, are not listed.
|
||||
#[test]
|
||||
fn go_to_lists_the_named_ranges_of_every_sheet() {
|
||||
let mut s = go_to_workbook();
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
|
||||
assert_eq!(list["recent"], json!([]), "{list}");
|
||||
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
|
||||
s.execute("selection.set", json!({"sheet": 1})).unwrap();
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
|
||||
let mut s = go_to_workbook();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
|
||||
assert_eq!(out["selection"], "C3", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
|
||||
// A name of several areas selects all of them.
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
|
||||
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
|
||||
}
|
||||
|
||||
/// A name matches in any case and any script, as a formula matches it.
|
||||
#[test]
|
||||
fn go_to_matches_a_sheet_name_in_any_case() {
|
||||
let mut s = go_to_workbook();
|
||||
s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap();
|
||||
assert_eq!(out["selection"], "D4", "{out}");
|
||||
// Seen from Sheet2, its own name hides the workbook name it shares.
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
||||
}
|
||||
|
||||
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
|
||||
/// each with a comma ... Excel will highlight all the ranges."
|
||||
#[test]
|
||||
fn go_to_selects_every_reference_and_name_in_a_list() {
|
||||
let mut s = go_to_workbook();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
|
||||
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
|
||||
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
|
||||
assert_eq!(out["selection"], "A1,B2", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
|
||||
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
|
||||
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
|
||||
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
|
||||
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
|
||||
assert_eq!(parts.next(), Some("A1"));
|
||||
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
|
||||
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
|
||||
assert_eq!(parts.next(), None);
|
||||
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
|
||||
let mut s = go_to_workbook();
|
||||
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
|
||||
assert!(err.to_string().contains("at most"), "{err}");
|
||||
let bad = format!("Total{}", ",".repeat(1_000_000));
|
||||
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
|
||||
}
|
||||
|
||||
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
|
||||
#[test]
|
||||
fn go_to_keeps_the_references_entered_newest_first() {
|
||||
let mut s = go_to_workbook();
|
||||
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
|
||||
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
|
||||
// Names are listed already; a call without `record` (the Name Box) is not kept.
|
||||
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
|
||||
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
|
||||
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
|
||||
// Going back from Sheet2 to a recent reference returns to its sheet.
|
||||
s.execute("selection.set", json!({"sheet": 1})).unwrap();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
|
||||
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
|
||||
for i in 0..GO_TO_RECENT + 2 {
|
||||
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
|
||||
}
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
|
||||
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_set_takes_numbers_for_input_and_value() {
|
||||
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
||||
|
||||
@@ -212,6 +212,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
trust_scripts
|
||||
),
|
||||
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
|
||||
cmd!(
|
||||
noundo "automate.stopAction",
|
||||
"Stop Action",
|
||||
[],
|
||||
None,
|
||||
"{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began",
|
||||
has_doc,
|
||||
stop_action
|
||||
),
|
||||
// Cell controls, themes, borders, styles
|
||||
cmd!(
|
||||
"insert.checkbox",
|
||||
@@ -800,7 +809,7 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust);
|
||||
copy.selection = d.selection.clone();
|
||||
copy.scripts_trusted = d.scripts_trusted;
|
||||
let i = s.add_document(copy);
|
||||
@@ -932,7 +941,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("Nothing to import.".into()));
|
||||
}
|
||||
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
|
||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
|
||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
|
||||
});
|
||||
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
@@ -1025,6 +1034,11 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
||||
if id.starts_with("file.") || id.starts_with("link.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
// Scripts are scoped to their workbook: a step that switches the active window would
|
||||
// point every later step at another open document, so those are refused too.
|
||||
if id.starts_with("window.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
if params.get("path").is_some() {
|
||||
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
||||
}
|
||||
@@ -1087,6 +1101,13 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"ran": n}))
|
||||
}
|
||||
|
||||
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
|
||||
/// Redo work there again.
|
||||
fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let uid = s.doc()?.uid;
|
||||
Ok(json!({"stopped": s.abort_groups(uid)}))
|
||||
}
|
||||
|
||||
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
|
||||
edit(s, |cx| {
|
||||
@@ -1249,7 +1270,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
|
||||
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}"))));
|
||||
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(name) => {
|
||||
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
|
||||
let title = crate::sample::title(name);
|
||||
s.add_document(DocState::new(wb, None, title))
|
||||
s.add_document(DocState::new(wb, None, title, crate::Trust::Local))
|
||||
}
|
||||
None => s.new_workbook(),
|
||||
};
|
||||
@@ -69,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
let title = if imported {
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
|
||||
} else {
|
||||
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
|
||||
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
|
||||
};
|
||||
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
|
||||
// workbook decrypted with its password, so Save never replaces the encrypted original with
|
||||
@@ -83,7 +83,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
s.close_document(0);
|
||||
}
|
||||
let mut doc = DocState::new(wb, path, title);
|
||||
// Someone else may have made the file: its active content asks before it runs.
|
||||
let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile);
|
||||
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
|
||||
doc.content_kind = Some(crate::io::FileKind::Xlsx);
|
||||
}
|
||||
@@ -113,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
|
||||
// document keeps its file and its unsaved changes.
|
||||
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
return super::print::export_pdf(s, &json!({"path": path}));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
|
||||
@@ -233,10 +233,23 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
|
||||
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Formatting on a protected sheet keeps the cell's permission flags: a format call
|
||||
// that flips `locked` from the format permission would authorize content edits that
|
||||
// the password should still gate, so those flags may not change (Excel keeps that
|
||||
// checkbox disabled while the sheet is protected).
|
||||
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
|
||||
macro_rules! guard_flags {
|
||||
($old:expr, $new:expr) => {
|
||||
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
|
||||
return Err(EngineError::Other(FLAG_GUARD.into()));
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
|
||||
for r in &ranges {
|
||||
let full_rows = r.is_full_rows();
|
||||
@@ -251,6 +264,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
info.and_then(|i| i.style).unwrap_or_default()
|
||||
};
|
||||
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
|
||||
guard_flags!(cur, new);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
|
||||
map.entry(line).or_default().style = Some(new);
|
||||
@@ -260,6 +274,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
|
||||
for (c, st) in &cells {
|
||||
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
|
||||
guard_flags!(*st, new);
|
||||
cx.sheet_mut(sheet)?.set_style(*c, new);
|
||||
}
|
||||
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
|
||||
@@ -276,6 +291,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
|
||||
if new != st {
|
||||
guard_flags!(st, new);
|
||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula};
|
||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -32,21 +32,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Define Name…",
|
||||
["Formulas", "Defined Names"],
|
||||
None,
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
|
||||
has_doc,
|
||||
define_name
|
||||
),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
|
||||
cmd!(
|
||||
"formulas.deleteName",
|
||||
"Delete Name",
|
||||
[],
|
||||
None,
|
||||
"{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
|
||||
has_doc,
|
||||
delete_name
|
||||
),
|
||||
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
|
||||
cmd!(
|
||||
"formulas.createFromSelection",
|
||||
"Create from Selection",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("Cmd+Shift+F3"),
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
|
||||
has_doc,
|
||||
create_from_selection
|
||||
),
|
||||
cmd!(
|
||||
"formulas.pasteName",
|
||||
"Paste Names…",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("F3"),
|
||||
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
|
||||
has_doc,
|
||||
paste_name
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
@@ -268,6 +285,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !valid_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() {
|
||||
refuse_stash_name(&s.doc()?.wb, p, n)?;
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let scope = match str_param(p, "scope") {
|
||||
None | Some("Workbook") => None,
|
||||
@@ -374,8 +394,29 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]):
|
||||
/// defining, renaming or deleting one would replace or remove part of an item (a stored macro,
|
||||
/// script or view) and leave the rest behind. `force: true` lets the call go ahead.
|
||||
/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope
|
||||
/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and
|
||||
/// the name commands may change it.
|
||||
fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool {
|
||||
// The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not.
|
||||
stash::holds_item(wb, name)
|
||||
}
|
||||
|
||||
fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> {
|
||||
if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
|
||||
refuse_stash_name(&s.doc()?.wb, p, &name)?;
|
||||
let scope = match str_param(p, "scope") {
|
||||
None => None,
|
||||
Some("Workbook") => Some(None),
|
||||
@@ -461,7 +502,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
|
||||
// reported.
|
||||
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
|
||||
// A label that names one of GridCraft's own hidden data names would replace part of an item
|
||||
// (see `refuse_stash_name`).
|
||||
let force = bool_param(p, "force").unwrap_or(false);
|
||||
let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n);
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n)));
|
||||
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
|
||||
let n = made.len();
|
||||
edit(s, |cx| {
|
||||
@@ -474,6 +519,61 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
|
||||
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
|
||||
/// workbook name it shares.
|
||||
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
|
||||
let sheet = wb.active_sheet;
|
||||
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
|
||||
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
||||
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
|
||||
for n in shown(false) {
|
||||
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
|
||||
out.push((n.name.clone(), format!("={}", n.formula)));
|
||||
}
|
||||
}
|
||||
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let names = pasteable_names(&d.wb);
|
||||
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
|
||||
if !bool_param(p, "list").unwrap_or(false) {
|
||||
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
|
||||
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
|
||||
};
|
||||
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
|
||||
}
|
||||
return Ok(json!({"names": listed}));
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(EngineError::Other("This workbook has no names to paste.".into()));
|
||||
}
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
|
||||
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
|
||||
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
|
||||
}
|
||||
let sheet = d.wb.active_sheet;
|
||||
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
|
||||
edit(s, |cx| {
|
||||
for (row, (name, refers)) in (at.row..).zip(&names) {
|
||||
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
|
||||
let c = CellRef::new(row, col);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(c);
|
||||
// The second column holds the refers-to text as a text value, with no formula.
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
}
|
||||
Ok(json!({"names": listed, "range": range.a1()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
@@ -628,8 +728,12 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let circ: Vec<String> =
|
||||
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
|
||||
let circ: Vec<String> = d
|
||||
.calc
|
||||
.circular
|
||||
.iter()
|
||||
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
|
||||
.collect();
|
||||
Ok(json!({"errors": out, "circular": circ}))
|
||||
}
|
||||
|
||||
@@ -742,6 +846,57 @@ mod tests {
|
||||
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_commands_leave_gridcraft_data_alone() {
|
||||
// Defining, renaming or deleting a name of a stored item's shape would replace or remove
|
||||
// one of its chunks and leave the rest behind: refused unless `force` is true.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let text = "m".repeat(9_000);
|
||||
stash::set(wb, "macro_1", &text).unwrap();
|
||||
wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true });
|
||||
// A legacy item is read whether or not it is hidden, so a visible one is data too.
|
||||
wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false });
|
||||
let names = |s: &Session| s.doc().unwrap().wb.names.clone();
|
||||
let before = names(&s);
|
||||
assert!(before.len() >= 3, "the item takes two chunks");
|
||||
for params in [
|
||||
json!({"name": "_gc_macro_1_0", "refersTo": "=1"}),
|
||||
json!({"name": "_SC_Watch", "refersTo": "=1"}),
|
||||
json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}),
|
||||
json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}),
|
||||
json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}),
|
||||
] {
|
||||
let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string();
|
||||
assert!(err.contains("force"), "{params}: {err}");
|
||||
}
|
||||
for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] {
|
||||
let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string();
|
||||
assert!(err.contains("force"), "{name}: {err}");
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap();
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap();
|
||||
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"])));
|
||||
let after = names(&s);
|
||||
assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::<Vec<_>>(), before);
|
||||
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()));
|
||||
// A visible name of the shape is the person's own (the stash never reads it): the name
|
||||
// commands change and remove it without `force`, as Name Manager cannot pass one.
|
||||
let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false };
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone());
|
||||
s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap();
|
||||
s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own);
|
||||
s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap();
|
||||
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched");
|
||||
// Ordinary names and forced calls go ahead.
|
||||
s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap();
|
||||
s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap();
|
||||
assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_defined_names_count_characters_not_bytes() {
|
||||
let mut s = Session::new();
|
||||
@@ -790,4 +945,92 @@ mod tests {
|
||||
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
|
||||
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
|
||||
}
|
||||
|
||||
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
|
||||
/// `Tax`, and a name of another sheet.
|
||||
fn named_workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
|
||||
name: name.into(),
|
||||
scope,
|
||||
formula: formula.into(),
|
||||
comment: String::new(),
|
||||
hidden,
|
||||
};
|
||||
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
|
||||
wb.names.push(name("TAX", Some(0), "0.25", false));
|
||||
wb.names.push(name("Other", Some(1), "1", false));
|
||||
s
|
||||
}
|
||||
|
||||
fn text_at(s: &Session, cell: &str) -> Value {
|
||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_names_lists_the_names_the_active_sheet_can_use() {
|
||||
let mut s = named_workbook();
|
||||
let r = s.execute("formulas.pasteName", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
r["names"],
|
||||
json!([
|
||||
{"name": "rate", "refersTo": "=0.07"},
|
||||
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
|
||||
{"name": "TAX", "refersTo": "=0.25"},
|
||||
])
|
||||
);
|
||||
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
|
||||
let mut s = named_workbook();
|
||||
s.take_ui_requests();
|
||||
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
|
||||
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
|
||||
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
|
||||
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
|
||||
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
|
||||
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_list_writes_each_name_and_what_it_refers_to() {
|
||||
let mut s = named_workbook();
|
||||
s.execute("selection.set", json!({"range": "D2"})).unwrap();
|
||||
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
|
||||
assert_eq!(r["range"], json!("D2:E4"));
|
||||
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
|
||||
for (cell, text) in rows {
|
||||
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
for (cell, _) in rows {
|
||||
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
|
||||
}
|
||||
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
|
||||
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_list_needs_names_and_room() {
|
||||
let mut s = named_workbook();
|
||||
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
|
||||
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
|
||||
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
|
||||
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
|
||||
let mut empty = Session::new();
|
||||
empty.new_workbook();
|
||||
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
|
||||
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -582,6 +582,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
|
||||
("pie", _) => ChartKind::Pie,
|
||||
("doughnut", _) => ChartKind::Doughnut,
|
||||
("area", "stacked") => ChartKind::AreaStacked,
|
||||
("area", "stacked100") => ChartKind::AreaStacked100,
|
||||
("area", _) => ChartKind::Area,
|
||||
("scatter", "lines") => ChartKind::ScatterLines,
|
||||
("scatter", _) => ChartKind::Scatter,
|
||||
|
||||
@@ -7,11 +7,11 @@ use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect),
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
|
||||
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
|
||||
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
|
||||
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
|
||||
]
|
||||
}
|
||||
@@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"title": d.display_title(),
|
||||
"path": d.path,
|
||||
"dirty": d.is_dirty(),
|
||||
"trust": d.trust,
|
||||
"activeSheet": wb.active_sheet,
|
||||
"sheets": sheets,
|
||||
"selection": d.selection.a1(),
|
||||
@@ -148,9 +149,11 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn history(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>()}),
|
||||
)
|
||||
Ok(json!({
|
||||
"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
|
||||
"redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
|
||||
"grouping": s.in_group(d.uid),
|
||||
}))
|
||||
}
|
||||
|
||||
fn list_commands(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod sheet;
|
||||
pub mod slicer;
|
||||
pub mod solver;
|
||||
pub mod spelling;
|
||||
pub mod stash;
|
||||
pub mod view;
|
||||
pub mod whatif;
|
||||
|
||||
@@ -96,12 +97,19 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
not_grouping(s)?;
|
||||
if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
not_grouping(s)?;
|
||||
if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
|
||||
}
|
||||
/// Undo and Redo wait while an undo group is open on the active document (an action that spans
|
||||
/// several calls is still running).
|
||||
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
|
||||
}
|
||||
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
|
||||
@@ -157,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
test_commands().iter().find(|c| c.id == id).copied()
|
||||
}
|
||||
|
||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Test-only: register an extra command so UI integration tests can prove a pending
|
||||
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
|
||||
@@ -179,6 +187,23 @@ pub fn register_test_command(id: &'static str, run: Run) {
|
||||
test_commands().push(spec);
|
||||
}
|
||||
|
||||
/// Test-only: an undoable command, to exercise how `execute` records its step.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||||
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
|
||||
id,
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
journal: false,
|
||||
undoable: true,
|
||||
}));
|
||||
test_commands().push(spec);
|
||||
}
|
||||
|
||||
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
|
||||
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
|
||||
match TEST_COMMANDS.lock() {
|
||||
@@ -527,9 +552,15 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
return;
|
||||
}
|
||||
let mut need: f32 = default;
|
||||
// The cell loop must not rescan every merge per cell: the merges touching this row are
|
||||
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
|
||||
// a sheet with many merges and many saved columns would otherwise scan all of them per
|
||||
// cell, rows × merges × columns).
|
||||
let row_merges: Vec<gridcraft_core::RangeRef> =
|
||||
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
|
||||
@@ -184,10 +184,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show Details",
|
||||
[],
|
||||
None,
|
||||
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}",
|
||||
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).",
|
||||
has_doc,
|
||||
show_details
|
||||
),
|
||||
cmd!(
|
||||
"pivot.options",
|
||||
"PivotTable Options",
|
||||
["PivotTable Analyze", "PivotTable"],
|
||||
None,
|
||||
"{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}",
|
||||
has_doc,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"pivot.delete",
|
||||
"Delete PivotTable",
|
||||
@@ -244,7 +253,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
}
|
||||
|
||||
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
|
||||
match v.map(|x| x.to_ascii_lowercase()) {
|
||||
match v.map(str::to_ascii_lowercase) {
|
||||
Some(a) if a == "rows" || a == "row" => Ok("rows"),
|
||||
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
|
||||
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
|
||||
@@ -755,8 +764,13 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
|
||||
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
||||
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?;
|
||||
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
|
||||
// With the option off, a report that is out of date or a source that cannot be read is
|
||||
// reported as the option; only a cell that is not a value cell keeps its own message.
|
||||
if !pt.enable_show_details && !matches!(found, Ok(None)) {
|
||||
return Err(other(SHOW_DETAILS_OFF));
|
||||
}
|
||||
let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
@@ -824,6 +838,32 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// The message Show Details gives while Enable show details is off.
|
||||
const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable.";
|
||||
|
||||
/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not
|
||||
/// refreshed. Without params it returns the current setting.
|
||||
fn options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
let enable = bool_param(p, "enableShowDetails");
|
||||
if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() {
|
||||
return Err(bad("pivot.options", "`enableShowDetails` must be true or false"));
|
||||
}
|
||||
let current = |s: &Session| -> Result<Json> {
|
||||
let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
Ok(json!({"enableShowDetails": pt.enable_show_details}))
|
||||
};
|
||||
let Some(on) = enable else { return current(s) };
|
||||
if current(s)?["enableShowDetails"] == json!(on) {
|
||||
return current(s);
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
pt.enable_show_details = on;
|
||||
Ok(json!({"enableShowDetails": on}))
|
||||
})
|
||||
}
|
||||
|
||||
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let name = match str_param(p, "table") {
|
||||
@@ -861,6 +901,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
"valuesOnRows": pt.values_on_rows,
|
||||
"valuesPosition": pt.values_position,
|
||||
"enableShowDetails": pt.enable_show_details,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2335,6 +2376,7 @@ mod tests {
|
||||
"pivot.changeSource",
|
||||
"pivot.delete",
|
||||
"pivot.showDetails",
|
||||
"pivot.options",
|
||||
];
|
||||
let hostile = [
|
||||
json!({}),
|
||||
@@ -2580,4 +2622,72 @@ mod tests {
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_show_details_off_stops_show_details() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true);
|
||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
||||
assert!(!pt(&s).enable_show_details);
|
||||
assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false);
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
||||
assert_eq!(e, "Show Details is turned off for this PivotTable.");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
// A label still reports that it is not a value cell.
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string();
|
||||
assert!(!e.contains("turned off"), "{e}");
|
||||
// Setting the same value again adds no undo step.
|
||||
let after = s.doc().unwrap().revision;
|
||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, after);
|
||||
assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err());
|
||||
// One undo turns the option back on.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(pt(&s).enable_show_details);
|
||||
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_show_details_off_is_reported_before_an_out_of_date_report() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
||||
// The source changes after the report was written, so the report is out of date.
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
||||
assert!(e.contains("turned off") && !e.contains("out of date"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_cells_are_the_cells_show_details_takes() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
for layout in 0..2 {
|
||||
if layout == 1 {
|
||||
// A two-level row axis, with subtotals at the top of each group.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
}
|
||||
let d = s.doc().unwrap();
|
||||
let si = d.wb.active_sheet;
|
||||
let pt = &d.wb.active().unwrap().pivots[0];
|
||||
let r = pt.last_range.unwrap();
|
||||
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
|
||||
let mut values = 0;
|
||||
for row in 0..r.height() {
|
||||
for col in 0..r.width() {
|
||||
let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap();
|
||||
assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})");
|
||||
values += usize::from(one.is_some());
|
||||
}
|
||||
}
|
||||
assert!(values > 4, "layout {layout} has value cells");
|
||||
}
|
||||
// An out-of-date report answers as Show Details does.
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+175
-94
@@ -139,8 +139,26 @@ impl SheetLayout {
|
||||
let [l, r, t, b, ..] = self.margins;
|
||||
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
|
||||
}
|
||||
/// Left and right margins, in points, that header and footer sections line up with: the
|
||||
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
|
||||
/// most a quarter of the page width.
|
||||
pub(crate) fn hf_sides(&self) -> (f32, f32) {
|
||||
if self.settings.hf_align_with_margins {
|
||||
(self.margins[0], self.margins[1])
|
||||
} else {
|
||||
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
|
||||
(side, side)
|
||||
}
|
||||
}
|
||||
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
|
||||
pub(crate) fn hf_text_scale(&self) -> f32 {
|
||||
if self.settings.hf_scale_with_doc { self.scale } else { 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
|
||||
const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
|
||||
|
||||
/// Options shared by the print commands.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PrintOptions {
|
||||
@@ -599,7 +617,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
i += rest.chars().count() + 1;
|
||||
}
|
||||
d if d.is_ascii_digit() => {
|
||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
|
||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
|
||||
i += digits.len() - 1;
|
||||
if let Ok(v) = digits.parse::<f32>() {
|
||||
sec.size = Some(v.clamp(1.0, 409.0));
|
||||
@@ -784,22 +802,27 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
// Break a single over-long word by characters.
|
||||
// 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 mut cut = String::new();
|
||||
let mut rest = String::new();
|
||||
for ch in line.chars() {
|
||||
if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() {
|
||||
cut.push(ch);
|
||||
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 {
|
||||
rest.push(ch);
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
if lo == chars.len() {
|
||||
break;
|
||||
}
|
||||
out.push(cut);
|
||||
line = rest;
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
return out;
|
||||
@@ -1306,7 +1329,16 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
|
||||
}
|
||||
|
||||
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
|
||||
fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) {
|
||||
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
|
||||
/// picture again, but the decode happens once).
|
||||
fn draw_objects(
|
||||
pg: &mut Page,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
sh: &Sheet,
|
||||
b: &Block,
|
||||
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
|
||||
) {
|
||||
let block = b.range();
|
||||
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
|
||||
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
|
||||
@@ -1316,6 +1348,42 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
|
||||
let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k);
|
||||
pg.save();
|
||||
pg.clip_rect(bx, by, bw, bh);
|
||||
draw_sparklines(pg, wb, si, sh, b, block);
|
||||
for (imi, im) in sh.images.iter().enumerate() {
|
||||
let r = anchor_rect(sh, &im.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
// About 150 dpi is plenty for print.
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
if let Some((px, pw, ph)) = decoded.as_ref() {
|
||||
pg.image_rgba(x, y, w, h, px, *pw, *ph);
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
let r = anchor_rect(sh, &shp.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
draw_shape(pg, wb, shp, b, (x, y, w, h));
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
let r = anchor_rect(sh, &ch.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, _, _) = place(r);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) {
|
||||
for sp in &sh.sparklines {
|
||||
if !block.contains(sp.cell) {
|
||||
continue;
|
||||
@@ -1330,90 +1398,64 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
|
||||
let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
for im in &sh.images {
|
||||
let r = anchor_rect(sh, &im.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) {
|
||||
let fill = rgb_of(shp.fill, wb);
|
||||
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
|
||||
match shp.kind {
|
||||
ShapeKind::Ellipse => {
|
||||
let pts: Vec<[f32; 2]> = (0..72)
|
||||
.map(|i| {
|
||||
let a = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
pg.polygon(&pts, fill, line);
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
// About 150 dpi is plenty for print.
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) {
|
||||
pg.image_rgba(x, y, w, h, &px, pw, ph);
|
||||
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
|
||||
ShapeKind::Line | ShapeKind::Arrow => {
|
||||
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
|
||||
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
|
||||
if shp.kind == ShapeKind::Arrow {
|
||||
let ang = h.atan2(w);
|
||||
let al = 8.0 * b.k;
|
||||
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
|
||||
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
let r = anchor_rect(sh, &shp.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
let fill = rgb_of(shp.fill, wb);
|
||||
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
|
||||
match shp.kind {
|
||||
ShapeKind::Ellipse => {
|
||||
let pts: Vec<[f32; 2]> = (0..72)
|
||||
.map(|i| {
|
||||
let a = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
pg.polygon(&pts, fill, line);
|
||||
}
|
||||
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
|
||||
ShapeKind::Line | ShapeKind::Arrow => {
|
||||
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
|
||||
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
|
||||
if shp.kind == ShapeKind::Arrow {
|
||||
let ang = h.atan2(w);
|
||||
let al = 8.0 * b.k;
|
||||
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
|
||||
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
|
||||
}
|
||||
}
|
||||
if !shp.text.is_empty() {
|
||||
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
|
||||
let fm = &shp.format;
|
||||
let text_box = shp.kind == ShapeKind::TextBox;
|
||||
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
|
||||
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
|
||||
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
|
||||
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
|
||||
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
|
||||
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
|
||||
let block_h = lines.len().min(500) as f32 * size * 1.2;
|
||||
let mut yy = match anchor {
|
||||
ShapeTextAnchor::Top => y + 2.0 * b.k,
|
||||
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
|
||||
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
|
||||
} + size * 0.8;
|
||||
for l in lines.iter().take(500) {
|
||||
let lw = gridcraft_pdf::text_width(font, size, l);
|
||||
let lx = match align {
|
||||
ShapeTextAlign::Left => x + 2.0 * b.k,
|
||||
ShapeTextAlign::Center => x + (w - lw) / 2.0,
|
||||
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
|
||||
};
|
||||
pg.text(lx, yy, size, font, color, l);
|
||||
yy += size * 1.2;
|
||||
}
|
||||
}
|
||||
if shp.text.is_empty() {
|
||||
return;
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
let r = anchor_rect(sh, &ch.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, _, _) = place(r);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
|
||||
let fm = &shp.format;
|
||||
let text_box = shp.kind == ShapeKind::TextBox;
|
||||
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
|
||||
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
|
||||
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
|
||||
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
|
||||
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
|
||||
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
|
||||
let block_h = lines.len().min(500) as f32 * size * 1.2;
|
||||
let mut yy = match anchor {
|
||||
ShapeTextAnchor::Top => y + 2.0 * b.k,
|
||||
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
|
||||
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
|
||||
} + size * 0.8;
|
||||
for l in lines.iter().take(500) {
|
||||
let lw = gridcraft_pdf::text_width(font, size, l);
|
||||
let lx = match align {
|
||||
ShapeTextAlign::Left => x + 2.0 * b.k,
|
||||
ShapeTextAlign::Center => x + (w - lw) / 2.0,
|
||||
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
|
||||
};
|
||||
pg.text(lx, yy, size, font, color, l);
|
||||
yy += size * 1.2;
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) {
|
||||
@@ -1452,7 +1494,9 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
|
||||
}
|
||||
|
||||
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
|
||||
let [l, r, _, _, hm, fm] = lay.margins;
|
||||
let [_, _, _, _, hm, fm] = lay.margins;
|
||||
let (l, r) = lay.hf_sides();
|
||||
let text_scale = lay.hf_text_scale();
|
||||
let pw = lay.page_w;
|
||||
let ph = lay.page_h;
|
||||
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
|
||||
@@ -1464,7 +1508,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::
|
||||
if sec.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0);
|
||||
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale;
|
||||
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
|
||||
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
|
||||
let n = lines.len() as f32;
|
||||
@@ -1528,6 +1572,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let mut page_no = 0usize;
|
||||
let mut pending_links = Vec::new();
|
||||
let mut printed_blocks = Vec::new();
|
||||
// Decoded pictures are reused across the pages their anchor covers: a floating picture
|
||||
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
|
||||
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
@@ -1564,7 +1611,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
y += seg_size(sh, *rs, true) * k;
|
||||
}
|
||||
if let Some(b) = &body {
|
||||
draw_objects(pg, wb, lay.sheet, sh, b);
|
||||
draw_objects(pg, wb, lay.sheet, sh, b, &mut pics);
|
||||
}
|
||||
if lay.settings.headings {
|
||||
let fill = [0xF2, 0xF2, 0xF2];
|
||||
@@ -1994,6 +2041,40 @@ mod tests {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
/// The x position and font size of the first `(text) Tj` in a PDF.
|
||||
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
let before = &s[..s.find(&format!("({text}) Tj"))?];
|
||||
let tf = before.rfind(" Tf")?;
|
||||
let size = before[..tf].rsplit(' ').next()?.parse().ok()?;
|
||||
let tm = before.rfind(" Tm")?;
|
||||
let mut nums = before[..tm].rsplit([' ', '\n']);
|
||||
let _y = nums.next()?;
|
||||
let x = nums.next()?.parse().ok()?;
|
||||
Some((x, size))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap();
|
||||
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap();
|
||||
// Both on (the default): the text prints at half size, from the 1.5-inch left margin.
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0)));
|
||||
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0));
|
||||
let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap();
|
||||
assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false)));
|
||||
// Both off: full size, 0.7 inch from the paper's left edge.
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0)));
|
||||
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print;
|
||||
assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -7,7 +7,10 @@ use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
|
||||
str_param, target_range, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -270,6 +273,29 @@ fn cancel_copy_mode(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
|
||||
if at == 0 {
|
||||
return;
|
||||
}
|
||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||
if let Some(info) = prev {
|
||||
for i in at..at + count {
|
||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
@@ -279,13 +305,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// Refuse to push data off the sheet.
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
check_push_off_sheet(s, sheet, axis, at, count, limit)?;
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
@@ -297,15 +317,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, true);
|
||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||
if at > 0 {
|
||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||
if let Some(info) = prev {
|
||||
for i in at..at + count {
|
||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||
}
|
||||
}
|
||||
}
|
||||
inherit_line_format(sh, axis, at, count);
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||
cx.structural = true;
|
||||
@@ -401,8 +413,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()));
|
||||
}
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
|
||||
// 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)?;
|
||||
|
||||
let moved_from = if before <= from { from + count } else { from };
|
||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
@@ -586,11 +600,15 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let revision = d.revision;
|
||||
let before = (d.wb.clone(), d.selection.clone());
|
||||
// Inside an undo group the nested steps add no undo entries of their own.
|
||||
let nested_entries = !s.in_group(d.uid);
|
||||
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
|
||||
let d = s.doc_mut()?;
|
||||
// The nested steps collapse into this command's single undo step.
|
||||
for _ in 0..d.revision.saturating_sub(revision) {
|
||||
d.undo.pop();
|
||||
if nested_entries {
|
||||
for _ in 0..d.revision.saturating_sub(revision) {
|
||||
d.undo.pop();
|
||||
}
|
||||
}
|
||||
match result {
|
||||
Ok((final_range, out)) => {
|
||||
|
||||
@@ -28,8 +28,10 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::check_arrays;
|
||||
use super::whatif::Trial;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
|
||||
use gridcraft_core::split_sheet;
|
||||
|
||||
const CMD: &str = "data.solver";
|
||||
const REPORT_CMD: &str = "data.solverReport";
|
||||
@@ -1705,6 +1707,26 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
|
||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
|
||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
|
||||
(rel, rhs_cells) => {
|
||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||
let rhs_text = match rhs_cells {
|
||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||
_ => gridcraft_core::number_to_text(b),
|
||||
};
|
||||
let slack = (v - b).abs();
|
||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||
let slack = if binding { 0.0 } else { slack };
|
||||
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||
}
|
||||
};
|
||||
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
|
||||
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
|
||||
}
|
||||
|
||||
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||
let m = inp.model;
|
||||
let mut trial = Trial::new(d);
|
||||
@@ -1768,29 +1790,7 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||
Rhs::None => vec![],
|
||||
};
|
||||
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
|
||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
|
||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
|
||||
(rel, rhs_cells) => {
|
||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||
let rhs_text = match rhs_cells {
|
||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||
_ => gridcraft_core::number_to_text(b),
|
||||
};
|
||||
let slack = (v - b).abs();
|
||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||
let slack = if binding { 0.0 } else { slack };
|
||||
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||
}
|
||||
};
|
||||
rows.push(vec![
|
||||
Value::text(abs_ref(*cell)),
|
||||
Value::text(cell_name(sh, *cell)),
|
||||
num(*v),
|
||||
Value::text(formula),
|
||||
Value::text(status),
|
||||
num(slack),
|
||||
]);
|
||||
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
|
||||
}
|
||||
}
|
||||
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx
|
||||
//! file.
|
||||
//!
|
||||
//! An item `key` is stored as names `_gc_<key>_0`, `_gc_<key>_1`, …, each a formula of string
|
||||
//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a
|
||||
//! formula to 8,192, so a long value is split over as many names as it needs and the file stays
|
||||
//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep
|
||||
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
|
||||
//! format later, and a write of the same key replaces it.
|
||||
//!
|
||||
//! XML can't carry every character (controls other than tab, line feed and carriage return, and
|
||||
//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the
|
||||
//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits
|
||||
//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode
|
||||
//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on
|
||||
//! ordinary text. Legacy `_sc_` items are read as they are.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::{DefinedName, Workbook};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const PREFIX: &str = "_gc_";
|
||||
const LEGACY_PREFIX: &str = "_sc_";
|
||||
/// Characters (UTF-16 units, as Excel counts them) in one string literal.
|
||||
const LITERAL_UNITS: usize = 255;
|
||||
/// Characters in one name's formula.
|
||||
const FORMULA_UNITS: usize = 8_192;
|
||||
/// The longest key: `_gc_<key>_<k>` stays well inside Excel's 255-character names.
|
||||
const MAX_KEY: usize = 200;
|
||||
/// The longest item: 4,194,304 characters.
|
||||
pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024;
|
||||
/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character
|
||||
/// takes seven (the three-byte marker and four digits).
|
||||
const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7;
|
||||
/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter.
|
||||
pub const ESCAPE: char = '\u{FDD0}';
|
||||
/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A
|
||||
/// file can carry any number of hidden names; the rest are not listed.
|
||||
pub const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its
|
||||
/// legacy `_sc_` name.
|
||||
#[derive(Default)]
|
||||
struct Parts<'a> {
|
||||
chunks: BTreeMap<u64, &'a str>,
|
||||
legacy: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Parts<'a> {
|
||||
fn add(&mut self, chunk: Option<u64>, formula: &'a str) {
|
||||
match chunk {
|
||||
Some(i) => {
|
||||
self.chunks.entry(i).or_insert(formula);
|
||||
}
|
||||
None => self.legacy = self.legacy.or(Some(formula)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the names make an item: a later chunk on its own does not.
|
||||
fn is_item(&self) -> bool {
|
||||
self.chunks.contains_key(&0) || self.legacy.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Names [`group`] has looked at on this thread, so a test can bound the work of a listing.
|
||||
static NAME_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names
|
||||
/// that hold them, in one pass over the names.
|
||||
fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap<String, Parts<'a>> {
|
||||
let prefix = prefix.to_ascii_lowercase();
|
||||
let mut items: BTreeMap<String, Parts<'a>> = BTreeMap::new();
|
||||
for n in wb.names.iter() {
|
||||
#[cfg(test)]
|
||||
NAME_VISITS.with(|v| v.set(v.get().saturating_add(1)));
|
||||
let Some((k, chunk)) = item_of(n) else { continue };
|
||||
let k = k.to_ascii_lowercase();
|
||||
if k.starts_with(&prefix) {
|
||||
items.entry(k).or_default().add(chunk, &n.formula);
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// The item text stored under `key`, or `None` when there is none (or it is over the size limit).
|
||||
pub fn get(wb: &Workbook, key: &str) -> Option<String> {
|
||||
let mut parts = Parts::default();
|
||||
for n in wb.names.iter() {
|
||||
if let Some((k, chunk)) = item_of(n)
|
||||
&& k.eq_ignore_ascii_case(key)
|
||||
{
|
||||
parts.add(chunk, &n.formula);
|
||||
}
|
||||
}
|
||||
assemble(&parts)
|
||||
}
|
||||
|
||||
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
|
||||
/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit.
|
||||
pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap<String, String> {
|
||||
group(wb, prefix)
|
||||
.into_iter()
|
||||
.filter(|(_, parts)| parts.is_item())
|
||||
.take(MAX_ITEMS)
|
||||
.filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text of the item held by `parts`, as [`get`] reads it.
|
||||
fn assemble(parts: &Parts) -> Option<String> {
|
||||
let chunks = &parts.chunks;
|
||||
let mut out = String::new();
|
||||
if !chunks.contains_key(&0) {
|
||||
out = string_literals(parts.legacy?);
|
||||
} else {
|
||||
// Chunks run 0, 1, 2, …; anything after a gap isn't part of the item.
|
||||
for (want, (i, f)) in chunks.iter().enumerate() {
|
||||
if *i != want as u64 {
|
||||
break;
|
||||
}
|
||||
out.push_str(&string_literals(f));
|
||||
if out.len() > MAX_STORED_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
out = unescape(&out);
|
||||
}
|
||||
(out.chars().count() <= MAX_ITEM_CHARS).then_some(out)
|
||||
}
|
||||
|
||||
/// Whether `c` is stored escaped: XML can't carry it, or it is the marker.
|
||||
fn needs_escape(c: char) -> bool {
|
||||
c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four
|
||||
/// hex digits naming a character (a hand-made name) stays as it is.
|
||||
fn unescape(s: &str) -> String {
|
||||
if !s.contains(ESCAPE) {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(at) = rest.find(ESCAPE) {
|
||||
out.push_str(rest.get(..at).unwrap_or(""));
|
||||
let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or("");
|
||||
let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
match code.and_then(char::from_u32) {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
rest = after.get(4..).unwrap_or("");
|
||||
}
|
||||
None => {
|
||||
out.push(ESCAPE);
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
|
||||
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
|
||||
check_key(key)?;
|
||||
if text.chars().count() > MAX_ITEM_CHARS {
|
||||
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
|
||||
}
|
||||
let formulas = chunk_formulas(text);
|
||||
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
|
||||
// it with the same name would be the one an xlsx file drops. Refuse before changing anything,
|
||||
// in one pass over the names (a file can carry many thousands).
|
||||
let chunks = formulas.len() as u64;
|
||||
let taken = |n: &&DefinedName| {
|
||||
n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks))
|
||||
};
|
||||
if let Some(n) = wb.names.iter().find(taken) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
|
||||
n.name
|
||||
)));
|
||||
}
|
||||
remove(wb, key);
|
||||
for (i, formula) in formulas.into_iter().enumerate() {
|
||||
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the item stored under `key`.
|
||||
pub fn remove(wb: &mut Workbook, key: &str) {
|
||||
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key)));
|
||||
}
|
||||
|
||||
/// The item under `key` as JSON (`None` when missing or not JSON).
|
||||
pub fn get_json(wb: &Workbook, key: &str) -> Option<Json> {
|
||||
serde_json::from_str(&get(wb, key)?).ok()
|
||||
}
|
||||
|
||||
pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
|
||||
set(wb, key, &v.to_string())
|
||||
}
|
||||
|
||||
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
|
||||
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
|
||||
/// where a [`get`] per key would take one each.
|
||||
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
|
||||
group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect()
|
||||
}
|
||||
|
||||
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
|
||||
/// `_gc_<key>_<k>` name, or a workbook-scope legacy `_sc_<key>` name, which is read whether or not
|
||||
/// it is hidden. A visible `_gc_` name is the person's own.
|
||||
pub fn holds_item(wb: &Workbook, name: &str) -> bool {
|
||||
wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some())
|
||||
}
|
||||
|
||||
/// The item a hidden name belongs to, lower-cased: `_gc_<key>_<k>` and `_sc_<key>` give `<key>`.
|
||||
pub fn item_key(name: &str) -> Option<String> {
|
||||
parse_name(name).map(|(k, _)| k.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The item a name holds a part of: a hidden workbook-scope `_gc_<key>_<k>` name, or a legacy
|
||||
/// `_sc_<key>` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is
|
||||
/// neither read, nor listed, nor removed on write.
|
||||
fn item_of(n: &DefinedName) -> Option<(&str, Option<u64>)> {
|
||||
if n.scope.is_some() {
|
||||
return None;
|
||||
}
|
||||
parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden)
|
||||
}
|
||||
|
||||
/// `_gc_<key>_<k>` → (`key`, `Some(k)`); `_sc_<key>` → (`key`, `None`).
|
||||
fn parse_name(name: &str) -> Option<(&str, Option<u64>)> {
|
||||
if let Some(rest) = strip_prefix_ci(name, PREFIX) {
|
||||
let (key, k) = rest.rsplit_once('_')?;
|
||||
// Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not
|
||||
// chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`.
|
||||
let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0'));
|
||||
if key.is_empty() || !canonical {
|
||||
return None;
|
||||
}
|
||||
return k.parse().ok().map(|i| (key, Some(i)));
|
||||
}
|
||||
strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None))
|
||||
}
|
||||
|
||||
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let head = s.get(..prefix.len())?;
|
||||
head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten()
|
||||
}
|
||||
|
||||
fn check_key(key: &str) -> Result<()> {
|
||||
let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.');
|
||||
if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) }
|
||||
}
|
||||
|
||||
/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula
|
||||
/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]).
|
||||
/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back.
|
||||
pub fn chunk_formulas(text: &str) -> Vec<String> {
|
||||
let mut formulas = Vec::new();
|
||||
let (mut formula, mut units) = (String::new(), 0usize);
|
||||
let (mut literal, mut lit_units) = (String::from("\""), 0usize);
|
||||
let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| {
|
||||
literal.push('"');
|
||||
let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty());
|
||||
if *units + cost > FORMULA_UNITS && !formula.is_empty() {
|
||||
formulas.push(std::mem::take(formula));
|
||||
*units = 0;
|
||||
}
|
||||
if !formula.is_empty() {
|
||||
formula.push('&');
|
||||
*units += 1;
|
||||
}
|
||||
*units += literal.encode_utf16().count();
|
||||
formula.push_str(literal);
|
||||
*literal = String::from("\"");
|
||||
*lit_units = 0;
|
||||
};
|
||||
for ch in text.chars() {
|
||||
// An escaped character is the marker and four digits, kept in one literal.
|
||||
let escaped = needs_escape(ch);
|
||||
let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() };
|
||||
if lit_units + cost > LITERAL_UNITS {
|
||||
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
|
||||
}
|
||||
lit_units += cost;
|
||||
if escaped {
|
||||
literal.push(ESCAPE);
|
||||
let _ = write!(literal, "{:04X}", u32::from(ch));
|
||||
} else {
|
||||
if ch == '"' {
|
||||
literal.push('"');
|
||||
}
|
||||
literal.push(ch);
|
||||
}
|
||||
}
|
||||
if lit_units > 0 {
|
||||
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
|
||||
}
|
||||
if !formula.is_empty() {
|
||||
formulas.push(formula);
|
||||
}
|
||||
formulas
|
||||
}
|
||||
|
||||
/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored).
|
||||
pub fn string_literals(f: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut it = f.chars().peekable();
|
||||
let mut in_str = false;
|
||||
while let Some(c) = it.next() {
|
||||
if !in_str {
|
||||
if c == '"' {
|
||||
in_str = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
if it.peek() == Some(&'"') {
|
||||
it.next();
|
||||
out.push('"');
|
||||
} else {
|
||||
in_str = false;
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn name(n: &str, formula: &str) -> DefinedName {
|
||||
DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true }
|
||||
}
|
||||
|
||||
fn excel_limits_hold(wb: &Workbook) {
|
||||
for n in &wb.names {
|
||||
assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len());
|
||||
// Every literal (quotes undoubled) is at most 255 characters.
|
||||
for lit in n.formula.split("\"&\"") {
|
||||
let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\"");
|
||||
assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_values_split_over_names_and_come_back() {
|
||||
let mut wb = Workbook::new();
|
||||
// Quotes double when written; characters outside the BMP count twice.
|
||||
let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect();
|
||||
set(&mut wb, "big", &text).unwrap();
|
||||
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
|
||||
assert!(names.len() > 5, "{names:?}");
|
||||
assert_eq!(names[0], "_gc_big_0");
|
||||
assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none()));
|
||||
excel_limits_hold(&wb);
|
||||
assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive");
|
||||
// Shorter again: the extra chunks go.
|
||||
set(&mut wb, "big", "short").unwrap();
|
||||
assert_eq!(wb.names.len(), 1);
|
||||
assert_eq!(wb.names[0].formula, "\"short\"");
|
||||
set(&mut wb, "big", "").unwrap();
|
||||
assert!(wb.names.is_empty());
|
||||
assert_eq!(get(&wb, "big"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_and_literals_respect_the_boundaries() {
|
||||
for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] {
|
||||
let text = "q".repeat(len);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text, "{len}");
|
||||
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}");
|
||||
}
|
||||
// All quotes: each literal doubles in the formula.
|
||||
let text = "\"".repeat(9_000);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text);
|
||||
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_single_name_still_reads_and_is_replaced_on_write() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\""));
|
||||
wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\""));
|
||||
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]})));
|
||||
assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2])));
|
||||
set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap();
|
||||
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]);
|
||||
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_names_read_safely() {
|
||||
let mut wb = Workbook::new();
|
||||
// A gap: chunk 2 without chunk 1.
|
||||
wb.names.push(name("_gc_gap_0", "\"a\""));
|
||||
wb.names.push(name("_gc_gap_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "gap").as_deref(), Some("a"));
|
||||
// Only a later chunk: no item, not listed, and the legacy name is still read.
|
||||
wb.names.push(name("_gc_orphan_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "orphan"), None);
|
||||
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
|
||||
wb.names.push(name("_sc_orphan", "\"old\""));
|
||||
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
|
||||
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
|
||||
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
|
||||
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
|
||||
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
|
||||
wb.names.push(name("_gc_mix_0", "\"ignored\""));
|
||||
wb.names.push(name("_gc_mix_2", "\"c"));
|
||||
assert_eq!(get(&wb, "mix").as_deref(), Some("abc"));
|
||||
// Names that only look like chunks.
|
||||
for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] {
|
||||
wb.names.push(name(odd, "\"zzz\""));
|
||||
}
|
||||
assert_eq!(get(&wb, "x"), None);
|
||||
assert_eq!(get(&wb, ""), None);
|
||||
// Not JSON, or JSON nested deeper than the parser allows.
|
||||
wb.names.push(name("_gc_notjson_0", "\"{\""));
|
||||
assert_eq!(get_json(&wb, "notjson"), None);
|
||||
let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000));
|
||||
wb.names.push(name("_gc_deep_0", &deep));
|
||||
assert_eq!(get_json(&wb, "deep"), None);
|
||||
// Sheet-scoped names are someone else's.
|
||||
wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") });
|
||||
assert_eq!(get(&wb, "scoped"), None);
|
||||
// Bad keys are refused.
|
||||
for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] {
|
||||
assert!(set(&mut wb, bad, "v").is_err(), "{bad}");
|
||||
}
|
||||
assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_items_are_not_read() {
|
||||
let mut wb = Workbook::new();
|
||||
let chunk = format!("\"{}\"", "x".repeat(250));
|
||||
let big = vec![chunk.as_str(); 32].join("&");
|
||||
for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) {
|
||||
wb.names.push(name(&format!("_gc_huge_{i}"), &big));
|
||||
}
|
||||
assert_eq!(get(&wb, "huge"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visible_name_of_the_item_shape_is_not_an_item() {
|
||||
// Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and
|
||||
// writing or removing the item leaves it alone.
|
||||
let mut wb = Workbook::new();
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
|
||||
// keeps only one of them: refused, with the names as they were.
|
||||
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
|
||||
assert!(err.contains("_gc_watch_0"), "{err}");
|
||||
assert_eq!(wb.names, vec![own.clone()]);
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
set(&mut wb, "other", "theirs").unwrap();
|
||||
assert_eq!(get(&wb, "other").as_deref(), Some("theirs"));
|
||||
remove(&mut wb, "watch");
|
||||
remove(&mut wb, "other");
|
||||
assert_eq!(wb.names, vec![own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_number_with_leading_zeros_is_another_name() {
|
||||
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
|
||||
// visible one is not in the way of writing `_gc_watch_0`.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_watch_00", "\"theirs\""));
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
set(&mut wb, "watch", "ours").unwrap();
|
||||
assert_eq!(get(&wb, "watch").as_deref(), Some("ours"));
|
||||
assert_eq!(wb.names.len(), 3, "both other names stay");
|
||||
remove(&mut wb, "watch");
|
||||
assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_list_items_by_prefix() {
|
||||
let mut wb = Workbook::new();
|
||||
set(&mut wb, "macro_2", "b").unwrap();
|
||||
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
|
||||
set(&mut wb, "scripts", "{}").unwrap();
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
|
||||
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
|
||||
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
|
||||
assert_eq!(item_key("Total"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_many_items_takes_one_pass_and_is_capped() {
|
||||
// 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each
|
||||
// key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS.
|
||||
let mut wb = Workbook::new();
|
||||
let total = 20_000;
|
||||
for i in 0..total {
|
||||
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
|
||||
}
|
||||
set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap();
|
||||
wb.names.push(name("_sc_macro_legacy", "\"old\""));
|
||||
wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\""));
|
||||
wb.names.push(name("_gc_other_0", "\"not a macro\""));
|
||||
NAME_VISITS.with(|v| v.set(0));
|
||||
let all = items(&wb, "MACRO_");
|
||||
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
|
||||
assert_eq!(all.len(), MAX_ITEMS);
|
||||
let first: Vec<&String> = all.keys().take(2).collect();
|
||||
assert_eq!(first, ["macro_00000", "macro_00001"]);
|
||||
assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999"));
|
||||
assert!(!all.contains_key("macro_10000"), "past the cap");
|
||||
NAME_VISITS.with(|v| v.set(0));
|
||||
let listed = keys(&wb, "macro_");
|
||||
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len());
|
||||
assert_eq!(listed, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
|
||||
|
||||
// Under the cap every item is there, read as `get` reads it.
|
||||
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
|
||||
let few = items(&wb, "macro_");
|
||||
let want: BTreeMap<String, String> =
|
||||
[("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect();
|
||||
assert_eq!(few, want);
|
||||
for (k, text) in &few {
|
||||
assert_eq!(get(&wb, k).as_ref(), Some(text));
|
||||
}
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]);
|
||||
assert_eq!(items(&wb, "").len(), 3, "the other item too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_round_trip_through_xlsx() {
|
||||
let mut wb = Workbook::new();
|
||||
let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect();
|
||||
set(&mut wb, "blob", &text).unwrap();
|
||||
wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\""));
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"])));
|
||||
|
||||
// Characters XML can't carry, and the escape marker itself, come back too, in text and
|
||||
// in JSON (which leaves U+FFFE and U+FFFF unescaped).
|
||||
let mut wb = Workbook::new();
|
||||
let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect();
|
||||
let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect();
|
||||
set(&mut wb, "odd", &text).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"});
|
||||
set_json(&mut wb, "json", &json).unwrap();
|
||||
// All escapes: each literal holds whole escapes, within the limits.
|
||||
let controls = "\u{1}".repeat(9_000);
|
||||
set(&mut wb, "controls", &controls).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get_json(&back, "json"), Some(json));
|
||||
assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() {
|
||||
// Excel's limits hold however many characters are escaped, and the longest item of
|
||||
// characters that all need escaping still reads.
|
||||
for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] {
|
||||
let text = "\u{2}".repeat(len);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::<String>()), text, "{len}");
|
||||
assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}");
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let longest = "\u{0}".repeat(MAX_ITEM_CHARS);
|
||||
set(&mut wb, "longest", &longest).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS));
|
||||
// A marker in a hand-made name that is not an escape stays as it is.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\""));
|
||||
assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}"));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
|
||||
Ok(json!({"on": s.prefs.formula_bar}))
|
||||
}),
|
||||
cmd!(
|
||||
query "view.statusBar",
|
||||
"Customize Status Bar",
|
||||
[],
|
||||
None,
|
||||
"{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}",
|
||||
always,
|
||||
status_bar
|
||||
),
|
||||
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
||||
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
||||
cmd!(
|
||||
@@ -115,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Header & Footer",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
|
||||
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}",
|
||||
has_doc,
|
||||
header_footer
|
||||
),
|
||||
@@ -187,6 +196,30 @@ fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
fn status_bar(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let st = &mut s.prefs.status_bar;
|
||||
for (key, flag) in [
|
||||
("average", &mut st.average),
|
||||
("count", &mut st.count),
|
||||
("numericalCount", &mut st.numerical_count),
|
||||
("minimum", &mut st.minimum),
|
||||
("maximum", &mut st.maximum),
|
||||
("sum", &mut st.sum),
|
||||
] {
|
||||
if let Some(on) = bool_param(p, key) {
|
||||
*flag = on;
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"average": st.average,
|
||||
"count": st.count,
|
||||
"numericalCount": st.numerical_count,
|
||||
"minimum": st.minimum,
|
||||
"maximum": st.maximum,
|
||||
"sum": st.sum,
|
||||
}))
|
||||
}
|
||||
|
||||
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
|
||||
let d = s.doc_mut()?;
|
||||
@@ -392,6 +425,12 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(on) = bool_param(p, "differentOddEven") {
|
||||
pr.different_odd_even = on;
|
||||
}
|
||||
if let Some(on) = bool_param(p, "scaleWithDoc") {
|
||||
pr.hf_scale_with_doc = on;
|
||||
}
|
||||
if let Some(on) = bool_param(p, "alignWithMargins") {
|
||||
pr.hf_align_with_margins = on;
|
||||
}
|
||||
if let Some((page, footer, idx, text)) = §ion {
|
||||
let spec = pr.hf_mut(*page, *footer);
|
||||
let mut parts = super::print::split_header_footer(spec);
|
||||
@@ -405,6 +444,8 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"footer": pr.footer,
|
||||
"differentFirst": pr.different_first,
|
||||
"differentOddEven": pr.different_odd_even,
|
||||
"scaleWithDoc": pr.hf_scale_with_doc,
|
||||
"alignWithMargins": pr.hf_align_with_margins,
|
||||
"firstHeader": pr.first_header,
|
||||
"firstFooter": pr.first_footer,
|
||||
"evenHeader": pr.even_header,
|
||||
@@ -478,4 +519,22 @@ mod tests {
|
||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
||||
assert!(old.formula_bar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() {
|
||||
let mut s = Session::new();
|
||||
let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true});
|
||||
assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all);
|
||||
let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap();
|
||||
assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false}));
|
||||
assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum);
|
||||
// A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest.
|
||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
||||
assert_eq!(old.status_bar, crate::StatusBarStats::default());
|
||||
let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap();
|
||||
assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum);
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["statusBar"]["minimum"], true);
|
||||
assert_eq!(saved["statusBar"]["numericalCount"], false);
|
||||
}
|
||||
}
|
||||
|
||||
+147
-8
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
use gridcraft_numfmt::{FormatKind, NumberFormat};
|
||||
|
||||
use crate::Result;
|
||||
@@ -23,15 +23,43 @@ pub enum FillMode {
|
||||
ValuesOnly,
|
||||
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
||||
Extend,
|
||||
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
|
||||
/// A lone date steps by one unit; two or more step by the difference between the first two,
|
||||
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
|
||||
/// extends like a plain drag.
|
||||
Dates(DateUnit),
|
||||
}
|
||||
|
||||
/// The step of a [`FillMode::Dates`] fill.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DateUnit {
|
||||
Day,
|
||||
/// Monday to Friday: Saturday and Sunday are skipped.
|
||||
Weekday,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl FillMode {
|
||||
/// The modes that continue the source's series rather than copy it.
|
||||
fn extends_series(self) -> bool {
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
|
||||
/// and `h:mm` don't).
|
||||
fn is_date_format(fmt: &str) -> bool {
|
||||
NumberFormat::parse(fmt).kind() == FormatKind::Date
|
||||
}
|
||||
|
||||
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
|
||||
/// months or years: a number (not a formula) shown in a date format.
|
||||
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
|
||||
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
|
||||
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
|
||||
}
|
||||
|
||||
/// Built-in custom lists.
|
||||
fn lists() -> Vec<Vec<String>> {
|
||||
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
|
||||
@@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
};
|
||||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||||
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
|
||||
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
|
||||
let dates = match mode {
|
||||
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
|
||||
_ => None,
|
||||
};
|
||||
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
|
||||
match (mode, detected) {
|
||||
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
||||
(_, p) => p,
|
||||
@@ -130,7 +163,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
writes.push((*t, Some(existing)));
|
||||
continue;
|
||||
}
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
|
||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||
if let Some(f) = &cell.formula {
|
||||
if let Some(e) = f.expr() {
|
||||
@@ -204,9 +237,63 @@ enum Pattern {
|
||||
months: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
/// Dates stepping by weekdays, skipping Saturday and Sunday.
|
||||
Weekdays {
|
||||
last: f64,
|
||||
first: f64,
|
||||
step: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
|
||||
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
|
||||
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
|
||||
if !is_date_format(fmt) {
|
||||
return None;
|
||||
}
|
||||
let mut nums = Vec::with_capacity(cells.len());
|
||||
for (_, cell) in cells {
|
||||
let cell = cell.as_ref()?;
|
||||
if cell.formula.is_some() || !cell.value.is_number() {
|
||||
return None;
|
||||
}
|
||||
nums.push(cell.value.as_f64()?);
|
||||
}
|
||||
let first = *nums.first()?;
|
||||
let last = *nums.last()?;
|
||||
let second = nums.get(1).copied();
|
||||
// The difference between the first two dates in the unit, and one unit when they fall in
|
||||
// the same one.
|
||||
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
|
||||
Some(match unit {
|
||||
DateUnit::Day => {
|
||||
let days = second.map_or(1.0, |b| b.floor() - first.floor());
|
||||
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
|
||||
}
|
||||
DateUnit::Weekday => {
|
||||
let step = match second {
|
||||
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
|
||||
None => 1,
|
||||
};
|
||||
Pattern::Weekdays { last, first, step, sys }
|
||||
}
|
||||
DateUnit::Month | DateUnit::Year => {
|
||||
let months = match second {
|
||||
Some(b) => {
|
||||
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
|
||||
let years = b.year as i64 - a.year as i64;
|
||||
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
|
||||
}
|
||||
None if unit == DateUnit::Year => 12,
|
||||
None => 1,
|
||||
};
|
||||
Pattern::Months { last, first, months, sys }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
||||
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
||||
@@ -343,20 +430,72 @@ impl Pattern {
|
||||
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
||||
Some(Value::number(s))
|
||||
}
|
||||
Pattern::Weekdays { last, first, step, sys } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
|
||||
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
|
||||
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
|
||||
}
|
||||
|
||||
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
|
||||
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
|
||||
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
|
||||
if n == 0 {
|
||||
return Some(serial);
|
||||
}
|
||||
let dir = n.signum() as f64;
|
||||
let mut d = serial;
|
||||
let next_weekday = |d: &mut f64| -> Option<()> {
|
||||
loop {
|
||||
*d += dir;
|
||||
if is_weekday(sys, *d)? {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
};
|
||||
next_weekday(&mut d)?;
|
||||
// From a weekday, every seven days hold five weekdays.
|
||||
let rest = n.unsigned_abs() - 1;
|
||||
d += dir * (rest / 5 * 7) as f64;
|
||||
for _ in 0..rest % 5 {
|
||||
next_weekday(&mut d)?;
|
||||
}
|
||||
datetime_from_serial(sys, d)?;
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
|
||||
/// counting whole days; `None` outside the supported date range.
|
||||
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
|
||||
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
|
||||
let days = (hi - lo) as i64;
|
||||
let mut count = days / 7 * 5;
|
||||
let mut d = lo + (days / 7 * 7) as f64;
|
||||
for _ in 0..days % 7 {
|
||||
d += 1.0;
|
||||
if is_weekday(sys, d)? {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Some(count * sign)
|
||||
}
|
||||
|
||||
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
|
||||
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
|
||||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
|
||||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
|
||||
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
|
||||
let d = datetime_from_serial(sys, serial)?;
|
||||
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
|
||||
let year = total.div_euclid(12);
|
||||
let month = total.rem_euclid(12) + 1;
|
||||
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
|
||||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
|
||||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
|
||||
}
|
||||
|
||||
fn round15(x: f64) -> f64 {
|
||||
@@ -388,9 +527,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
|
||||
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
||||
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
||||
let chars: Vec<char> = t.chars().collect();
|
||||
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
|
||||
let end = chars.iter().rposition(char::is_ascii_digit)?;
|
||||
let mut start = end;
|
||||
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
|
||||
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) {
|
||||
start -= 1;
|
||||
}
|
||||
let digits: String = chars.get(start..=end)?.iter().collect();
|
||||
|
||||
+129
-7
@@ -209,7 +209,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
|
||||
/// 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(|s| s.to_str()).unwrap_or("Sheet1");
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
|
||||
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.
|
||||
@@ -226,7 +226,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
// An unknown or missing extension is refused rather than written as an XLSX under that name:
|
||||
// a typo like `.cvs` would otherwise produce a file no other app recognises.
|
||||
let Some(kind) = FileKind::from_path(path) else {
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
|
||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
|
||||
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
|
||||
};
|
||||
save_bytes_as(wb, path, kind)
|
||||
@@ -239,7 +239,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
FileKind::Xlsx => {
|
||||
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
@@ -448,6 +448,11 @@ impl std::fmt::Write for ClipboardHtml {
|
||||
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
|
||||
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
|
||||
|
||||
/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used
|
||||
/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a
|
||||
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
|
||||
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
|
||||
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
@@ -479,6 +484,17 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
r.height()
|
||||
)));
|
||||
}
|
||||
// The table is one growing String: every kept row writes a `<td>` for the whole used
|
||||
// column span (unless the sheet has no row that is that wide). A tall, wide sheet
|
||||
// (or a sparse one with a lone far-right cell) would be gigabytes before finishing,
|
||||
// so the rectangle itself is bounded (#2387).
|
||||
if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS {
|
||||
return Err(EngineError::Other(format!(
|
||||
"the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.",
|
||||
r.height(),
|
||||
r.width()
|
||||
)));
|
||||
}
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
@@ -588,14 +604,27 @@ fn script_sandbox_check(path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The largest native file the reader still touches: its own cap plus one, used to stop
|
||||
/// reads whose bytes arrive faster than metadata can claim (device files, growing files).
|
||||
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
script_sandbox_check(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > 2 * 1024 * 1024 * 1024 {
|
||||
let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// A symlink to an endless device reports length 0 but streams forever, so metadata
|
||||
// alone never bounds the read: require a real file and cap the bytes actually taken.
|
||||
if !f.metadata().map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
||||
return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
let mut limited = (&f).take(MAX_READ_BYTES + 1);
|
||||
limited.read_to_end(&mut buf).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if buf.len() as u64 > MAX_READ_BYTES {
|
||||
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -606,10 +635,35 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
script_sandbox_check(path)?;
|
||||
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
|
||||
// The temporary file is created exclusively, so an existing file or symlink at that
|
||||
// path (planted by another local user) makes the save fail instead of truncating the
|
||||
// link's target; and it inherits the destination's access restrictions, so saving a
|
||||
// private workbook doesn't widen who can read it.
|
||||
let tmp = format!("{path}.sctmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
if let Some(p) = &prior {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
opts.mode(p.mode());
|
||||
}
|
||||
let mut f = opts.open(&tmp).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})?;
|
||||
#[cfg(not(unix))]
|
||||
if let Some(p) = &prior {
|
||||
let _ = f.set_permissions(p.clone());
|
||||
}
|
||||
f.write_all(bytes).and_then(|()| f.flush()).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
// A write or rename that fails (full disk, the workbook locked by another app) leaves
|
||||
// no temporary file beside the user's workbook.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
@@ -711,6 +765,74 @@ mod tests {
|
||||
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn save_fails_upfront_when_the_temp_name_is_taken() {
|
||||
// Another local user who can write the directory must not be able to point the
|
||||
// save's predictable temporary name at their own file: creation is exclusive.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let victim = dir.join("victim.txt");
|
||||
std::fs::write(&victim, b"original").unwrap();
|
||||
let dest = dir.join("book.xlsx");
|
||||
std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap();
|
||||
let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"original");
|
||||
assert!(!dir.join("book.xlsx").exists(), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn save_keeps_the_destination_access_restrictions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("private.xlsx");
|
||||
std::fs::write(&dest, b"v1").unwrap();
|
||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_file(dest.to_str().unwrap(), b"v2").unwrap();
|
||||
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
// And a brand-new file is created with the restrictive, not permissive, default.
|
||||
let fresh = dir.join("new.xlsx");
|
||||
write_file(fresh.to_str().unwrap(), b"v").unwrap();
|
||||
let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777;
|
||||
assert!(mode & 0o022 == 0, "mode {mode:o}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn open_refuses_directories_and_unknowable_lengths() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file");
|
||||
assert!(read_file(dir.join("missing").to_str().unwrap()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
|
||||
// #2387: the used rectangle alone bounds the output (every row writes the whole
|
||||
// column span), so a sparse sheet with one cell at A1 and another far right
|
||||
// 300 rows down is 4.9M cells → refused before a byte of `<td>` is written.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into()));
|
||||
sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into()));
|
||||
let err = to_html(&wb, 0).unwrap_err();
|
||||
assert!(err.to_string().contains("too tall × wide"), "{err}");
|
||||
// A normal sheet is fine.
|
||||
let mut ok = Workbook::new();
|
||||
ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into()));
|
||||
assert!(to_html(&ok, 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_embeds_images_as_data_uris() {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
+473
-11
@@ -19,6 +19,7 @@ pub mod outline;
|
||||
pub mod paper;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod record;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
@@ -66,6 +67,51 @@ pub struct HistoryEntry {
|
||||
pub retained_bytes: usize,
|
||||
}
|
||||
|
||||
/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
|
||||
/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[must_use = "an undo group stays open until it is ended or rolled back"]
|
||||
pub struct GroupToken {
|
||||
id: u64,
|
||||
doc: u64,
|
||||
}
|
||||
|
||||
impl GroupToken {
|
||||
/// The uid of the document the group belongs to.
|
||||
pub fn doc(&self) -> u64 {
|
||||
self.doc
|
||||
}
|
||||
}
|
||||
|
||||
/// An open undo group: what runs on `doc` until it ends becomes one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
struct OpenGroup {
|
||||
id: u64,
|
||||
doc: u64,
|
||||
label: String,
|
||||
/// Undo depth when the group began; the group's own steps are cut back to it.
|
||||
start: usize,
|
||||
wb: Arc<Workbook>,
|
||||
selection: Selection,
|
||||
/// The redo stack when the group began (a rollback puts it back).
|
||||
redo: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
|
||||
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
|
||||
/// command that ended the group tells its own later changes from the group's step by it, and
|
||||
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
|
||||
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
|
||||
/// workbook alive between commands.
|
||||
#[derive(Debug)]
|
||||
struct GroupStep {
|
||||
doc: u64,
|
||||
after: Arc<Workbook>,
|
||||
selection: Selection,
|
||||
len: usize,
|
||||
stepped: bool,
|
||||
}
|
||||
|
||||
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Clipboard {
|
||||
@@ -84,6 +130,34 @@ pub struct Clipboard {
|
||||
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
|
||||
/// together into one block at `range`. Empty for a single-range copy.
|
||||
pub areas: Vec<RangeRef>,
|
||||
/// Trust of the source document: pasting content from a file someone else made carries it.
|
||||
pub trust: Trust,
|
||||
}
|
||||
|
||||
/// Whether a document's active content (macros) may run without asking the person first. There
|
||||
/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new
|
||||
/// way of building one from bytes can't come out trusted by omission.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Trust {
|
||||
/// Made in this session: a new workbook or a sample.
|
||||
Local,
|
||||
/// Opened from a file, or holding content pasted from one: active content asks before it
|
||||
/// runs.
|
||||
FromFile,
|
||||
}
|
||||
|
||||
impl Trust {
|
||||
/// Whether active content may run without asking.
|
||||
pub fn allows_code(self) -> bool {
|
||||
self == Trust::Local
|
||||
}
|
||||
|
||||
/// The trust of a document after it receives content from a document with trust `source`:
|
||||
/// content from an untrusted file leaves the receiver untrusted.
|
||||
pub fn receive(self, source: Trust) -> Trust {
|
||||
if source == Trust::FromFile { Trust::FromFile } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// In-cell edit mode as shown in the status bar.
|
||||
@@ -120,6 +194,14 @@ pub struct DocState {
|
||||
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
|
||||
/// trusted, since any script it gains later was written here.
|
||||
pub scripts_trusted: bool,
|
||||
/// Where the workbook's content came from: whether its active content may run without
|
||||
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
|
||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||
/// changed by a trust decision.
|
||||
pub trust: Trust,
|
||||
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
|
||||
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
|
||||
pub go_to_recent: Vec<String>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -160,17 +242,31 @@ fn push_history_with_budget(
|
||||
selection: Selection,
|
||||
byte_budget: usize,
|
||||
) {
|
||||
push_entry(history, label, wb, next, selection);
|
||||
trim_to_budget(history, byte_budget);
|
||||
}
|
||||
|
||||
/// Adds a step without applying the limits: inside a command they wait for the outermost
|
||||
/// command to collapse its steps (see [`Session::trim_history`]).
|
||||
fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
|
||||
let retained_bytes = history_retained_bytes(&wb, next);
|
||||
history.push(HistoryEntry { label, wb, selection, retained_bytes });
|
||||
}
|
||||
|
||||
/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
|
||||
/// (even if that one edit exceeds the budget).
|
||||
fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
|
||||
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
|
||||
// Always preserve the latest action, even if that one edit exceeds the budget.
|
||||
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
|
||||
total = total.saturating_sub(history.remove(0).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
|
||||
/// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything
|
||||
/// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook
|
||||
/// made in this session.
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
@@ -191,6 +287,8 @@ impl DocState {
|
||||
unlocked_ranges: vec![],
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
trust,
|
||||
go_to_recent: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -253,6 +351,27 @@ pub struct Prefs {
|
||||
pub user_dictionary: Vec<String>,
|
||||
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
|
||||
pub formula_bar: bool,
|
||||
/// The statistics the status bar shows for a selection (right-click the status bar).
|
||||
pub status_bar: StatusBarStats,
|
||||
}
|
||||
|
||||
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
|
||||
/// Average, Count and Sum by default.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct StatusBarStats {
|
||||
pub average: bool,
|
||||
pub count: bool,
|
||||
pub numerical_count: bool,
|
||||
pub minimum: bool,
|
||||
pub maximum: bool,
|
||||
pub sum: bool,
|
||||
}
|
||||
|
||||
impl Default for StatusBarStats {
|
||||
fn default() -> Self {
|
||||
StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -267,6 +386,7 @@ impl Default for Prefs {
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
status_bar: StatusBarStats::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,6 +398,9 @@ pub enum UiRequest {
|
||||
Message(String),
|
||||
/// Start editing the active cell (F2) with optional initial text.
|
||||
EditCell(Option<String>),
|
||||
/// Put a defined name at the caret of the cell being edited, in place of selected text; with
|
||||
/// no edit under way, start editing the active cell with `=` and the name.
|
||||
InsertName(String),
|
||||
OpenUrl(String),
|
||||
}
|
||||
|
||||
@@ -307,8 +430,13 @@ pub struct Session {
|
||||
pub last_find: Option<Json>,
|
||||
/// View › Show › Ruler is off (rulers in Page Layout view; an application option).
|
||||
pub hide_ruler: bool,
|
||||
/// Journal position where action recording started.
|
||||
/// Journal position where Automate › Record Actions started.
|
||||
pub recording: Option<usize>,
|
||||
/// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level
|
||||
/// commands with the place they ran in, kept apart from the journal.
|
||||
pub recorder: Option<record::Recorder>,
|
||||
/// How many `execute` calls are running (0 between top-level commands).
|
||||
depth: u32,
|
||||
/// Windows hidden with View › Hide.
|
||||
pub hidden_windows: Vec<usize>,
|
||||
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
|
||||
@@ -320,6 +448,17 @@ pub struct Session {
|
||||
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
|
||||
/// (like Excel's comment indicator option), so it's not saved in the file.
|
||||
pub show_all_notes: bool,
|
||||
/// Open undo groups, innermost last.
|
||||
groups: Vec<OpenGroup>,
|
||||
next_group: u64,
|
||||
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
|
||||
group_epoch: u64,
|
||||
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
|
||||
group_step: Option<GroupStep>,
|
||||
/// For each running command, innermost last: its document and that document's undo length
|
||||
/// when it began. Steps before that length are not touched while the command runs (see
|
||||
/// [`Session::bound_tail`]).
|
||||
marks: Vec<(u64, usize)>,
|
||||
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
|
||||
/// [`cmd::format::approx_text_width`].
|
||||
pub text_width: Option<TextWidth>,
|
||||
@@ -359,7 +498,8 @@ impl Session {
|
||||
}
|
||||
pub fn close_document(&mut self, i: usize) {
|
||||
if i < self.docs.len() {
|
||||
self.docs.remove(i);
|
||||
let d = self.docs.remove(i);
|
||||
self.groups.retain(|g| g.doc != d.uid);
|
||||
if self.active >= self.docs.len() {
|
||||
self.active = self.docs.len().saturating_sub(1);
|
||||
}
|
||||
@@ -373,7 +513,7 @@ impl Session {
|
||||
let name = wb.next_sheet_name();
|
||||
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
|
||||
}
|
||||
self.add_document(DocState::new(wb, None, format!("Book{n}")))
|
||||
self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local))
|
||||
}
|
||||
|
||||
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
|
||||
@@ -412,7 +552,8 @@ impl Session {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
|
||||
/// Runs a command by id. Undoable commands record a history step when the workbook changed.
|
||||
/// Runs a command by id. Undoable commands record a history step when the workbook changed,
|
||||
/// unless an undo group is open on the document (the group then makes the step).
|
||||
/// Panics inside commands are caught and reported; the workbook is restored.
|
||||
pub fn execute(&mut self, id: &str, params: Json) -> Result<Json> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
@@ -421,36 +562,355 @@ impl Session {
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
|
||||
let params = if params.is_null() { Json::Object(Default::default()) } else { params };
|
||||
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
|
||||
// Whether a group was open on the document before the command, and how long its history was.
|
||||
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
|
||||
// A command that panics leaves the history as it found it: the steps commands nested in it
|
||||
// made or undid, and the redo history they cleared, come back. A length is not enough,
|
||||
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
|
||||
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
|
||||
// copy; front ends run queries all the time.
|
||||
let query = !spec.journal && !spec.undoable;
|
||||
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
|
||||
#[cfg(test)]
|
||||
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
|
||||
self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))
|
||||
});
|
||||
// The groups open on the document before the command (usually none), for the same reason.
|
||||
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
|
||||
Some((uid, ..)) => {
|
||||
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
// The recorder keeps top-level calls with the place they ran in.
|
||||
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
|
||||
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
|
||||
None => (0, 0, String::new(), Selection::default()),
|
||||
});
|
||||
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
|
||||
let requests_before = self.ui_requests.len();
|
||||
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
|
||||
self.marks.extend(marked);
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms)));
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
if marked.is_some() {
|
||||
self.marks.pop();
|
||||
}
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
// Restore the workbook as it was.
|
||||
let msg = p
|
||||
.downcast_ref::<&str>()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| p.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".into());
|
||||
// Restore the workbook and its history as they were, and drop the undo groups the
|
||||
// command opened.
|
||||
self.groups.retain(|g| g.id < first_group);
|
||||
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
|
||||
if let Some((uid, wb, sel)) = &before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
|
||||
{
|
||||
d.wb = wb.clone();
|
||||
d.selection = sel.clone();
|
||||
if let Some((undo, redo)) = history_before {
|
||||
d.undo = undo;
|
||||
d.redo = redo;
|
||||
}
|
||||
// A group open before the command and ended or rolled back inside it stays
|
||||
// ended (its token went with the command), and keeps a step from its start to
|
||||
// the restored workbook, so Undo still returns to the state before the group.
|
||||
if !group_open
|
||||
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
|
||||
&& !Arc::ptr_eq(&wb, &d.wb)
|
||||
{
|
||||
push_entry(&mut d.undo, label, wb, &d.wb, selection);
|
||||
d.redo.clear();
|
||||
}
|
||||
d.calc.rebuild(&d.wb);
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
}
|
||||
return Err(EngineError::Internal(id.to_string(), msg));
|
||||
}
|
||||
};
|
||||
// A command that fails leaves none of its own undo groups open (a `?` between
|
||||
// `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a
|
||||
// command that succeeds keeps a group it opened (a debugger's start).
|
||||
if result.is_err() {
|
||||
self.rollback_groups_from(first_group);
|
||||
}
|
||||
if spec.journal && result.is_ok() {
|
||||
// A command that started recording is not part of the recording; one that stopped it
|
||||
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
|
||||
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
|
||||
// call is, so playing the recording back runs the command once, with its parameters.
|
||||
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
|
||||
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
|
||||
&& let Some(rec) = self.recorder.as_mut()
|
||||
{
|
||||
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) });
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// Inside an undo group the group owns the step. A command that ended a group open before
|
||||
// it leaves the group its step and makes one of its own for what it changed after the end.
|
||||
// A command that opened and closed its own groups makes one step of its own, which
|
||||
// replaces the steps those groups made.
|
||||
let epoch_changed = self.group_epoch != group_epoch;
|
||||
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
|
||||
let was_open = start.is_some_and(|(was_open, _)| was_open);
|
||||
if spec.undoable
|
||||
&& let Some((uid, wb, sel)) = before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&d.wb, &wb)
|
||||
{
|
||||
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
|
||||
let label = spec.label.trim_end_matches('…').to_string();
|
||||
if in_group_now {
|
||||
// A group this command opened and left open (a debugger's start) takes the
|
||||
// command's start as its own, so what the command changed before opening it is
|
||||
// part of the group's step and not a step of its own.
|
||||
if !was_open
|
||||
&& let Some((_, len)) = start
|
||||
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
|
||||
{
|
||||
g.start = len;
|
||||
g.wb = wb;
|
||||
g.selection = sel;
|
||||
}
|
||||
} else if epoch_changed && was_open {
|
||||
// The group it ended made its step; changes after the end, whether direct or by
|
||||
// nested commands, are the command's own step from where the group ended.
|
||||
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
|
||||
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
|
||||
{
|
||||
d.undo.truncate(step.len);
|
||||
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
|
||||
d.redo.clear();
|
||||
}
|
||||
} else {
|
||||
let mut label = label;
|
||||
if epoch_changed && let Some((_, len)) = start {
|
||||
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
|
||||
// That is so only when the one entry the command added is a group's step from
|
||||
// the command's own start to its own result: an entry a nested command made,
|
||||
// a group step of an earlier command (undone since), or a group's step that
|
||||
// the command edited past all name the step after the command instead.
|
||||
if d.undo.len() == len + 1
|
||||
&& let Some(last) = d.undo.last()
|
||||
&& Arc::ptr_eq(&last.wb, &wb)
|
||||
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
|
||||
{
|
||||
label = last.label.clone();
|
||||
}
|
||||
d.undo.truncate(len);
|
||||
}
|
||||
push_entry(&mut d.undo, label, wb, &d.wb, sel);
|
||||
d.redo.clear();
|
||||
}
|
||||
// Inside a group the redo history waits for the group: `group_end` clears it when the
|
||||
// group becomes a step, and `group_rollback` puts it back as it was.
|
||||
d.revision += 1;
|
||||
}
|
||||
// The history limit applies once the outermost command has collapsed its steps, so the
|
||||
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
} else if let Some(uid) = doc_uid {
|
||||
self.bound_tail(uid);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f`
|
||||
/// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made
|
||||
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
|
||||
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||||
let token = self.group_begin(label)?;
|
||||
let doc = token.doc();
|
||||
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
|
||||
// (outside `execute`, which drops a command's groups itself) finds no group left open.
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
|
||||
Ok(Ok(r)) => match self.group_end(token) {
|
||||
Ok(_) => Ok(r),
|
||||
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
|
||||
// an untouched new workbook), and the group went with it: nothing is left to undo.
|
||||
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
// The error from `f` is the one to report. A rollback fails only when the group
|
||||
// has already ended, and then there is nothing left to undo.
|
||||
let _ = self.group_rollback(token);
|
||||
Err(e)
|
||||
}
|
||||
Err(panic) => {
|
||||
let _ = self.group_rollback(token);
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens an undo group on the active document that can span several calls (for example a
|
||||
/// debugger that runs one step per call). Until [`Session::group_end`] or
|
||||
/// [`Session::group_rollback`], undoable commands on that document add no undo steps of their
|
||||
/// own, and Undo and Redo are unavailable there.
|
||||
pub fn group_begin(&mut self, label: &str) -> Result<GroupToken> {
|
||||
let d = self.doc()?;
|
||||
let g = OpenGroup {
|
||||
id: self.next_group,
|
||||
doc: d.uid,
|
||||
label: label.trim_end_matches('…').to_string(),
|
||||
start: d.undo.len(),
|
||||
wb: d.wb.clone(),
|
||||
selection: d.selection.clone(),
|
||||
redo: d.redo.clone(),
|
||||
};
|
||||
let token = GroupToken { id: g.id, doc: g.doc };
|
||||
self.next_group = self.next_group.saturating_add(1);
|
||||
self.group_epoch = self.group_epoch.wrapping_add(1);
|
||||
self.groups.push(g);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is
|
||||
/// nested in another open group on the same document, which then owns the step. Groups opened
|
||||
/// inside it and still open end with it. Returns whether the workbook changed.
|
||||
pub fn group_end(&mut self, token: GroupToken) -> Result<bool> {
|
||||
let g = self.take_group(&token)?;
|
||||
let outer = self.in_group(g.doc);
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) };
|
||||
// Steps pushed directly while the group was open fold into the group's step.
|
||||
d.undo.truncate(g.start);
|
||||
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
|
||||
if changed && !outer {
|
||||
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
|
||||
d.redo.clear();
|
||||
d.revision += 1;
|
||||
}
|
||||
result
|
||||
if !outer {
|
||||
self.group_step =
|
||||
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
|
||||
}
|
||||
// Inside a command, the outermost command applies the history limit when it finishes.
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Stops the running action on the document with uid `doc`: every undo group open on it is
|
||||
/// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo
|
||||
/// and redo history as they were when the outermost one began. Their tokens are then spent
|
||||
/// (ending or rolling one back reports that it has already ended). Returns whether a group
|
||||
/// was open. `automate.stopAction` runs it on the active document.
|
||||
pub fn abort_groups(&mut self, doc: u64) -> bool {
|
||||
let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false };
|
||||
// Rolling back the outermost group ends the ones opened inside it.
|
||||
self.group_rollback(GroupToken { id, doc }).is_ok()
|
||||
}
|
||||
|
||||
/// Rolls back every open undo group numbered `first` or later (the groups a failed command
|
||||
/// opened), outermost first on each document.
|
||||
fn rollback_groups_from(&mut self, first: u64) {
|
||||
let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect();
|
||||
for (id, doc) in opened {
|
||||
// A group inside one rolled back before it is already gone.
|
||||
if self.groups.iter().any(|g| g.id == id) {
|
||||
let _ = self.group_rollback(GroupToken { id, doc });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
|
||||
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
|
||||
/// Only steps after the latest mark go: a running command's start, an open group's start and
|
||||
/// the end of the last group are positions the command or group collapses its steps by when
|
||||
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
|
||||
fn bound_tail(&mut self, uid: u64) {
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
|
||||
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
|
||||
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
|
||||
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
|
||||
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
|
||||
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
|
||||
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
|
||||
/// an open group waits for the group to end, because the group remembers where its steps start.
|
||||
fn trim_history(&mut self) {
|
||||
let groups = &self.groups;
|
||||
for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
|
||||
trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends an undo group by putting back the workbook, selection, undo and redo history as they
|
||||
/// were when it began.
|
||||
pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> {
|
||||
let g = self.take_group(&token)?;
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) };
|
||||
if !Arc::ptr_eq(&d.wb, &g.wb) {
|
||||
d.wb = g.wb;
|
||||
d.calc.rebuild(&d.wb);
|
||||
}
|
||||
d.selection = g.selection;
|
||||
d.undo.truncate(g.start);
|
||||
d.redo = g.redo;
|
||||
d.revision += 1;
|
||||
// The record of the last group step stays when the document is back exactly where that
|
||||
// step left it (this group began there); otherwise the rollback is where the group ended.
|
||||
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
|
||||
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
|
||||
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts recording top-level commands into an empty buffer, replacing any recording in
|
||||
/// progress. Queries, failed commands and commands run by other commands are not recorded.
|
||||
pub fn start_recording(&mut self) {
|
||||
self.recorder = Some(record::Recorder::default());
|
||||
}
|
||||
|
||||
/// Stops recording and returns what was recorded (`None` when nothing was recording).
|
||||
pub fn stop_recording(&mut self) -> Option<record::Recorder> {
|
||||
self.recorder.take()
|
||||
}
|
||||
|
||||
/// Whether the recording buffer is collecting commands.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.recorder.is_some()
|
||||
}
|
||||
|
||||
/// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a
|
||||
/// command run by a command, and so on.
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.depth
|
||||
}
|
||||
|
||||
/// Whether an undo group is open on the document with this uid.
|
||||
pub fn in_group(&self, doc: u64) -> bool {
|
||||
self.groups.iter().any(|g| g.doc == doc)
|
||||
}
|
||||
|
||||
/// Removes the token's group and any group on the same document opened after it.
|
||||
fn take_group(&mut self, token: &GroupToken) -> Result<OpenGroup> {
|
||||
let ended = || EngineError::Other("That undo group has already ended.".into());
|
||||
let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?;
|
||||
let mut later = self.groups.split_off(at).into_iter();
|
||||
let g = later.next().ok_or_else(ended)?;
|
||||
self.groups.extend(later.filter(|o| o.doc != g.doc));
|
||||
self.group_epoch = self.group_epoch.wrapping_add(1);
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
pub fn run(&mut self, id: &str, params: Json) -> std::result::Result<Json, String> {
|
||||
@@ -468,6 +928,8 @@ impl Session {
|
||||
let r = self.execute(id, params);
|
||||
let requests = self.take_ui_requests();
|
||||
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
|
||||
// A call that only opened a dialog is not recorded (see `execute`), so the recording
|
||||
// has no step to take back here.
|
||||
if r.is_ok() && asked_for_file {
|
||||
let what = match id {
|
||||
"file.open" => "`path` or `base64`",
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
|
||||
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
|
||||
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
|
||||
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
|
||||
//! columns right of it, or above / left of it when the sheet's outline settings say so
|
||||
//! ([`Sheet::summary_below`], [`Sheet::summary_right`]).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gridcraft_model::LineInfo;
|
||||
use gridcraft_model::{LineInfo, Sheet};
|
||||
|
||||
/// The deepest outline level XLSX allows.
|
||||
pub const MAX_LEVEL: u8 = 7;
|
||||
|
||||
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
|
||||
/// just after `end`.
|
||||
/// just after `end`, or just before `start` when `summary_before` is set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OutlineGroup {
|
||||
pub level: u8,
|
||||
@@ -19,25 +20,43 @@ pub struct OutlineGroup {
|
||||
pub end: u32,
|
||||
/// Every detail line is hidden: the button shows +.
|
||||
pub collapsed: bool,
|
||||
/// The summary line is above (rows) or left of (columns) the detail.
|
||||
pub summary_before: bool,
|
||||
}
|
||||
|
||||
impl OutlineGroup {
|
||||
/// The summary line, where the − / + button sits.
|
||||
pub fn summary(&self) -> u32 {
|
||||
self.end.saturating_add(1)
|
||||
if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) }
|
||||
}
|
||||
/// Whether the summary line exists: a group that starts at the first line (summary before)
|
||||
/// or ends at the last one (summary after) has none.
|
||||
pub fn has_summary(&self) -> bool {
|
||||
!self.contains(self.summary())
|
||||
}
|
||||
pub fn contains(&self, line: u32) -> bool {
|
||||
line >= self.start && line <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail.
|
||||
pub fn summary_before(sh: &Sheet, rows: bool) -> bool {
|
||||
if rows { !sh.summary_below } else { !sh.summary_right }
|
||||
}
|
||||
|
||||
/// The deepest outline level used by these lines (0 = no outline).
|
||||
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
|
||||
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Every group, ordered by where it ends (inner groups before the group around them).
|
||||
/// Every group, with its summary line after the detail; see [`groups_with`].
|
||||
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
|
||||
groups_with(lines, false)
|
||||
}
|
||||
|
||||
/// Every group, ordered by where it ends (inner groups before the group around them), with its
|
||||
/// summary line before the detail when `summary_before` is set.
|
||||
pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
|
||||
let mut out = Vec::new();
|
||||
// Open group per level (index level - 1): start line and whether all its lines are hidden.
|
||||
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
|
||||
@@ -47,7 +66,7 @@ pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
|
||||
if let Some(slot) = open.get_mut(level as usize - 1)
|
||||
&& let Some((start, hidden)) = slot.take()
|
||||
{
|
||||
out.push(OutlineGroup { level, start, end, collapsed: hidden });
|
||||
out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -102,9 +121,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
g,
|
||||
vec![
|
||||
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
|
||||
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
|
||||
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
|
||||
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false },
|
||||
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false },
|
||||
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false },
|
||||
]
|
||||
);
|
||||
assert_eq!(g[1].summary(), 8);
|
||||
@@ -129,5 +148,22 @@ mod tests {
|
||||
assert_eq!(g.len(), 7);
|
||||
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
|
||||
assert_eq!(g[0].summary(), u32::MAX);
|
||||
assert!(!g[0].has_summary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_before_the_detail() {
|
||||
// Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5.
|
||||
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]);
|
||||
let g = groups_with(&l, true);
|
||||
let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied();
|
||||
assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true)));
|
||||
assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true)));
|
||||
// A group on the first line has no line before it.
|
||||
assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false)));
|
||||
let mut sh = Sheet::new("S");
|
||||
assert!(!summary_before(&sh, true) && !summary_before(&sh, false));
|
||||
sh.summary_below = false;
|
||||
assert!(summary_before(&sh, true) && !summary_before(&sh, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ pub struct PageGrid {
|
||||
pub gap: f64,
|
||||
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
|
||||
pub scale: f32,
|
||||
/// Left and right margins of headers and footers in layout units (the side margins, or a
|
||||
/// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off).
|
||||
pub hf_sides: [f64; 2],
|
||||
/// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on,
|
||||
/// otherwise `1 / scale`, so the text keeps its point size on paper.
|
||||
pub hf_text_k: f32,
|
||||
/// Paper size in points (after orientation), for the rulers.
|
||||
pub paper_pt: (f32, f32),
|
||||
pub row_bands: Vec<(u32, u32)>,
|
||||
@@ -65,6 +71,8 @@ impl PageGrid {
|
||||
let s = f64::from(lay.scale.clamp(0.1, 4.0));
|
||||
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
|
||||
let margins = lay.margins.map(to_layout);
|
||||
let (hf_left, hf_right) = lay.hf_sides();
|
||||
let hf_text_k = lay.hf_text_scale() / s as f32;
|
||||
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
|
||||
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
|
||||
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
|
||||
@@ -79,6 +87,8 @@ impl PageGrid {
|
||||
margins,
|
||||
gap: f64::from(PAGE_GAP) / s,
|
||||
scale: lay.scale,
|
||||
hf_sides: [to_layout(hf_left), to_layout(hf_right)],
|
||||
hf_text_k,
|
||||
paper_pt: (lay.page_w, lay.page_h),
|
||||
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
|
||||
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
|
||||
@@ -174,11 +184,12 @@ impl PageGrid {
|
||||
}
|
||||
|
||||
/// The header or footer section under the point: the top or bottom margin of a page, split
|
||||
/// into thirds between the side margins.
|
||||
/// into thirds between the header and footer side margins.
|
||||
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
|
||||
let (i, j) = self.page_at(x, y)?;
|
||||
let (px, py, _, _) = self.page_rect(i, j);
|
||||
let [l, r, t, b, ..] = self.margins;
|
||||
let [_, _, t, b, ..] = self.margins;
|
||||
let [l, r] = self.hf_sides;
|
||||
let footer = if y < py + t {
|
||||
false
|
||||
} else if y >= py + self.page_h - b {
|
||||
@@ -301,6 +312,31 @@ mod tests {
|
||||
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
|
||||
let mut wb = wb_with(10, 3);
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.print.scale = 50;
|
||||
sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3];
|
||||
}
|
||||
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
|
||||
let (px, py, _, _) = g.page_rect(0, 0);
|
||||
// Both on: the sections sit between the 2-inch side margins and the text scales with the cells.
|
||||
assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]);
|
||||
assert!((g.hf_text_k - 1.0).abs() < 1e-6);
|
||||
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2));
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.print.hf_scale_with_doc = false;
|
||||
sh.print.hf_align_with_margins = false;
|
||||
}
|
||||
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
|
||||
// Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the
|
||||
// text keeps its point size on paper.
|
||||
assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides);
|
||||
assert!((g.hf_text_k - 2.0).abs() < 1e-6);
|
||||
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_scale_and_hostile_settings_stay_bounded() {
|
||||
let mut wb = wb_with(3, 3);
|
||||
|
||||
+43
-18
@@ -11,7 +11,10 @@ use std::collections::{HashMap, HashSet};
|
||||
use gridcraft_core::date::datetime_from_serial;
|
||||
pub use gridcraft_core::quote_sheet;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{
|
||||
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
|
||||
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
|
||||
};
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
|
||||
@@ -816,11 +819,30 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
|
||||
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
|
||||
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
|
||||
let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None };
|
||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||
// Excel reads its PivotTable cache; without one, the report written by the last refresh
|
||||
// must still be what the source gives. Checked first: in a stale report even a cell that
|
||||
// computes as a label may show a value.
|
||||
// Checked first: in a stale report even a cell that computes as a label may show a value.
|
||||
check_current(wb, pt, pivot_sheet, &out)?;
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records =
|
||||
probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
}
|
||||
|
||||
/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds
|
||||
/// records: its value, subtotal and grand total cells, from one computation of the report. The
|
||||
/// same errors as [`detail_records`].
|
||||
pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<HashSet<(u32, u32)>, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) };
|
||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||
check_current(wb, pt, pivot_sheet, &out)?;
|
||||
Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect())
|
||||
}
|
||||
|
||||
/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable
|
||||
/// cache; without one, the report written by the last refresh must still be what the source gives.
|
||||
fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> {
|
||||
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
|
||||
let current = pt.last_range.is_some_and(|r| {
|
||||
r.height() == out.height().max(1)
|
||||
@@ -834,17 +856,17 @@ pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u
|
||||
if !current {
|
||||
return Err("The PivotTable report is out of date. Refresh it first.".into());
|
||||
}
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
|
||||
/// value cell at `at`, and the paths of every record the filters let through.
|
||||
/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column
|
||||
/// item paths of the value cell at `at`, the paths of every record the filters let through (when
|
||||
/// `paths` is set), and every value cell (when `cells` is set).
|
||||
struct Probe {
|
||||
at: (usize, usize),
|
||||
at: Option<(usize, usize)>,
|
||||
hit: Option<(Vec<u32>, Vec<u32>)>,
|
||||
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
|
||||
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
|
||||
cells: Option<HashSet<(usize, usize)>>,
|
||||
}
|
||||
|
||||
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
|
||||
@@ -959,8 +981,8 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
if !col_leaves.contains(&cp[..shown]) {
|
||||
col_leaves.insert(cp[..shown].to_vec());
|
||||
}
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
pr.paths.push((ri, rp.clone(), cp.clone()));
|
||||
if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) {
|
||||
paths.push((ri, rp.clone(), cp.clone()));
|
||||
}
|
||||
row_set.insert(rp);
|
||||
col_set.insert(cp);
|
||||
@@ -1326,10 +1348,13 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
_ => raw,
|
||||
},
|
||||
};
|
||||
if let Some(pr) = probe.as_deref_mut()
|
||||
&& pr.at == (r, l + j)
|
||||
{
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
if pr.at == Some((r, l + j)) {
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
}
|
||||
if let Some(cells) = pr.cells.as_mut() {
|
||||
cells.insert((r, l + j));
|
||||
}
|
||||
}
|
||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The recording buffer: the commands a person (or an agent) ran while recording, each with the
|
||||
//! document, sheet and selection it ran in.
|
||||
//!
|
||||
//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a
|
||||
//! script) is one step, so playing the recording back does not run the inner commands twice. The
|
||||
//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and
|
||||
//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]).
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::Selection;
|
||||
|
||||
/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`].
|
||||
pub const RECORD_LIMIT: usize = 100_000;
|
||||
/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full
|
||||
/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so
|
||||
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
|
||||
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// One recorded command and where it ran.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordedStep {
|
||||
/// Uid of the document that was active when the command ran (0 when none was open).
|
||||
pub doc: u64,
|
||||
/// That document's active sheet (index and name) just before the command ran.
|
||||
pub sheet: usize,
|
||||
pub sheet_name: String,
|
||||
/// That document's selection just before the command ran.
|
||||
pub selection: Selection,
|
||||
/// The command id and its parameters, without secrets.
|
||||
pub id: String,
|
||||
pub params: Json,
|
||||
}
|
||||
|
||||
/// The steps recorded since recording started.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Recorder {
|
||||
pub steps: Vec<RecordedStep>,
|
||||
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
|
||||
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
|
||||
/// what ran, never a sequence with gaps.
|
||||
pub dropped: usize,
|
||||
/// The size of `steps` serialized as JSON.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Adds a step, or counts it as dropped once the buffer is full.
|
||||
pub fn push(&mut self, step: RecordedStep) {
|
||||
// Once a step has been dropped, or the step count is full, the rest are counted without
|
||||
// being serialized (a `file.open` step holds a whole file).
|
||||
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
|
||||
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
|
||||
self.bytes = self.bytes.saturating_add(size);
|
||||
self.steps.push(step);
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option<Workbook> {
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
s.add_document(DocState::new(wb, None, title(name)));
|
||||
s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local));
|
||||
let ok = match name {
|
||||
"budget" => budget(&mut s),
|
||||
"sales" => sales(&mut s),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
|
||||
|
||||
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
|
||||
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
|
||||
|
||||
@@ -3,6 +3,11 @@ use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
thread_local! {
|
||||
/// Copies of a document's undo and redo history `execute` has taken on this thread.
|
||||
pub(crate) static HISTORY_COPIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn s() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
@@ -936,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
|
||||
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
|
||||
let mut s = s();
|
||||
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
|
||||
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
|
||||
// Friday 1/30/2026 is serial 46052.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
|
||||
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
|
||||
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
|
||||
// From Saturday 1/31, the first weekday is Monday 2/2.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
|
||||
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
|
||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
|
||||
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
|
||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
|
||||
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
|
||||
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
|
||||
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
|
||||
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
|
||||
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
|
||||
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
|
||||
// A number that isn't a date fills as a plain drag does: a lone number copies.
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
|
||||
assert_eq!(v(&s, "F2"), Value::Number(7.0));
|
||||
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
|
||||
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
|
||||
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
|
||||
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
|
||||
// a month apart, and the Series dialog by months.
|
||||
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
|
||||
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
|
||||
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
|
||||
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
|
||||
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
|
||||
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
|
||||
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
|
||||
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
|
||||
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
|
||||
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_number_in_a_date_format_offers_the_date_fills() {
|
||||
let mut s = s();
|
||||
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
|
||||
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
|
||||
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
|
||||
assert!(!dates(&s, "A4"), "a formula is not offered");
|
||||
assert!(!dates(&s, "A5"), "a time is not a date");
|
||||
assert!(!dates(&s, "A6"), "an empty cell is not a date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells_adjust() {
|
||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||
@@ -2779,6 +2864,42 @@ fn calculation_options_enable_iterative_calculation() {
|
||||
near(&s, "A1", 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_checking_quotes_sheet_names_in_circular_references() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
|
||||
let r = s.execute("formulas.errorChecking", json!({})).unwrap();
|
||||
assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"]));
|
||||
// The reference reads back in a formula and in Go To.
|
||||
let reference = r["circular"][0].as_str().unwrap().to_string();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(2.0));
|
||||
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string()));
|
||||
}
|
||||
|
||||
/// Undo and redo put cells back without calculating them; the circular references listed are
|
||||
/// those of the restored cells.
|
||||
#[test]
|
||||
fn undo_and_redo_list_the_circular_references_of_the_restored_cells() {
|
||||
let mut s = s();
|
||||
let listed = |s: &Session| s.doc().unwrap().calc.circular.iter().map(|(si, c)| (*si, c.a1())).collect::<Vec<_>>();
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "=A5+1"})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
|
||||
// Undoing the loop itself lists none.
|
||||
s.execute("edit.undo", json!({"steps": 2})).unwrap();
|
||||
assert!(listed(&s).is_empty());
|
||||
}
|
||||
|
||||
fn chart_data(s: &mut Session) {
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
@@ -4599,3 +4720,60 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
|
||||
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
|
||||
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
|
||||
}
|
||||
#[test]
|
||||
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
|
||||
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
|
||||
// content edits, so the format cells may not drop a cell's `locked` flag.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "hi"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
||||
s.execute("home.fontName", json!({"name": "Arial"})).unwrap();
|
||||
let err = s.execute("home.lockCell", json!({"range": "B2", "on": false})).expect_err("unlock through formatting");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
assert!(s.execute("cell.set", json!({"cell": "B2", "input": "x"})).is_err(), "B2 stays locked");
|
||||
// The same calls run once the sheet is unprotected.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
|
||||
assert_eq!(v(&s, "B2"), Value::text("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_checks_the_protection_of_the_named_sheet() {
|
||||
// The move's protection checks must authorize the sheet it modifies, not whichever sheet
|
||||
// happens to be active while the command names another one.
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let err = s.execute("sheet.moveColumns", json!({"sheet": "P", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
let pv: Vec<Value> = {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
|
||||
(0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect()
|
||||
};
|
||||
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
|
||||
// The active, unprotected sheet moves fine.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap();
|
||||
s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::text("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_switch_windows() {
|
||||
// Stored scripts are scoped to their workbook: a step that activates another window would
|
||||
// point every later step at another open document, so it is refused before any step runs.
|
||||
let mut s = s();
|
||||
s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap();
|
||||
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
|
||||
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
|
||||
assert!(err.to_string().contains("window.activate"), "{err}");
|
||||
// A benign script runs.
|
||||
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::number(2.0));
|
||||
}
|
||||
|
||||
mod seams;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype
|
||||
//! `stacked100`.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_model::ChartKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn kinds(s: &Session) -> Vec<ChartKind> {
|
||||
s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_chart_makes_a_hundred_percent_stacked_area() {
|
||||
let mut s = session();
|
||||
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() {
|
||||
let mut s = session();
|
||||
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
|
||||
s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ fn guarded<T>(step: usize, kind: &'static str, f: impl FnOnce() -> Result<T, Str
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
Ok(Err(e)) => Err(fail(step, kind, e)),
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(fail(step, kind, format!("panicked: {msg}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ fn opened() -> Session {
|
||||
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
|
||||
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.add_document(DocState::new(back, None, "dynamic".into()));
|
||||
s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile));
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() {
|
||||
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
|
||||
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
|
||||
let mut session = Session::new();
|
||||
session.add_document(DocState::new(reopened, None, "chains".into()));
|
||||
session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile));
|
||||
session.execute("formulas.calculateNow", json!({})).expect("recalc");
|
||||
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod autofit_filter_buttons;
|
||||
mod cf_formula_validate;
|
||||
mod chart_area_stacked100;
|
||||
mod chart_workbook_names;
|
||||
mod command_sequence_fuzz;
|
||||
mod cross_sheet_cut;
|
||||
|
||||
@@ -6,7 +6,8 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\
|
||||
fn with_name(name: &str, refers_to: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap();
|
||||
// `force`: the name commands refuse GridCraft's own data names otherwise.
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
|
||||
|
||||
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
|
||||
+14
-12
@@ -23,18 +23,20 @@ pub fn same_name(a: &str, b: &str) -> bool {
|
||||
if a.is_ascii() && b.is_ascii() {
|
||||
return a.eq_ignore_ascii_case(b);
|
||||
}
|
||||
let fold = |s: &str| -> String {
|
||||
s.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.flat_map(|c| match c {
|
||||
'ß' => ['s', 's'].into_iter().take(2),
|
||||
'ſ' => ['s', 's'].into_iter().take(1),
|
||||
'ς' => ['σ', 'σ'].into_iter().take(1),
|
||||
c => [c, c].into_iter().take(1),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
a == b || fold(a) == fold(b)
|
||||
a == b || fold_name(a) == fold_name(b)
|
||||
}
|
||||
|
||||
/// The case-folded form of a name: two names are [`same_name`] when their folded forms are equal.
|
||||
pub fn fold_name(s: &str) -> String {
|
||||
s.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.flat_map(|c| match c {
|
||||
'ß' => ['s', 's'].into_iter().take(2),
|
||||
'ſ' => ['s', 's'].into_iter().take(1),
|
||||
'ς' => ['σ', 'σ'].into_iter().take(1),
|
||||
c => [c, c].into_iter().take(1),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
|
||||
use gridcraft_core::{CellError, MAX_ROWS};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
use crate::lexer::{Tok, Token, tokenize_locale};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::fmt::Write;
|
||||
|
||||
use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
|
||||
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
|
||||
|
||||
|
||||
@@ -123,6 +123,60 @@ impl Criterion {
|
||||
Criterion { op, operand, bare_empty: false }
|
||||
}
|
||||
|
||||
fn matches_blank(&self, v: &Value) -> bool {
|
||||
let blank = matches!(v, Value::Empty);
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match v {
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_number(&self, v: &Value, n: f64) -> bool {
|
||||
let cell = match v {
|
||||
Value::Number(x) => Some(*x),
|
||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_text(&self, v: &Value, pat: &str) -> bool {
|
||||
match self.op {
|
||||
Op::Eq | Op::Ne => {
|
||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||
let cell_text = match v {
|
||||
Value::Text(t) => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||
if self.op == Op::Eq { m } else { !m }
|
||||
}
|
||||
_ => match v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_cut_text(&self, v: &Value, kept: &str) -> bool {
|
||||
match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cell value satisfies the criterion.
|
||||
pub(crate) fn matches(&self, v: &Value) -> bool {
|
||||
let v = match v {
|
||||
@@ -133,31 +187,8 @@ impl Criterion {
|
||||
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
|
||||
}
|
||||
match &self.operand {
|
||||
Operand::Blank => {
|
||||
let blank = matches!(v, Value::Empty);
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match &v {
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
Operand::Number(n) => {
|
||||
let cell = match &v {
|
||||
Value::Number(x) => Some(*x),
|
||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
Operand::Blank => self.matches_blank(&v),
|
||||
Operand::Number(n) => self.matches_number(&v, *n),
|
||||
Operand::Bool(b) => match &v {
|
||||
Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
|
||||
_ => self.op == Op::Ne,
|
||||
@@ -166,27 +197,8 @@ impl Criterion {
|
||||
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
|
||||
_ => self.op == Op::Ne,
|
||||
},
|
||||
Operand::Text(pat) => match self.op {
|
||||
Op::Eq | Op::Ne => {
|
||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||
let cell_text = match &v {
|
||||
Value::Text(t) => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||
if self.op == Op::Eq { m } else { !m }
|
||||
}
|
||||
_ => match &v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||
_ => false,
|
||||
},
|
||||
},
|
||||
Operand::CutText(kept) => match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
},
|
||||
Operand::Text(pat) => self.matches_text(&v, pat),
|
||||
Operand::CutText(kept) => self.matches_cut_text(&v, kept),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,15 +276,27 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool {
|
||||
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||
let p = tokenize(pattern);
|
||||
let t = lower_chars(text);
|
||||
match_from(&p, &t, true).is_some()
|
||||
let mut budget = MAX_STEPS;
|
||||
match_from(&p, &t, true, &mut budget).is_some()
|
||||
}
|
||||
|
||||
/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character
|
||||
/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead
|
||||
/// of quadratic — a call that spends more gives up and reports no match, quiet degradation
|
||||
/// like the other work caps on far-past-realistic input.
|
||||
const MAX_STEPS: u64 = 1 << 26;
|
||||
|
||||
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish).
|
||||
fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish,
|
||||
/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that
|
||||
/// would spend more than it has gives up and reports no match — quiet degradation, as with the
|
||||
/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char
|
||||
/// cell ceiling costs a few hundred thousand steps).
|
||||
fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<usize> {
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
loop {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
if pi == p.len() && (!whole || ti == t.len()) {
|
||||
return Some(ti);
|
||||
}
|
||||
@@ -307,14 +331,38 @@ fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
||||
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
||||
let p = tokenize(pattern);
|
||||
let t = lower_chars(text);
|
||||
// A leading run of stars never matters for a prefix match (it consumes nothing), so
|
||||
// dropping it lets the anchor below skip linearly through the text (#2382).
|
||||
let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count();
|
||||
let p = &p[skip.min(p.len())..];
|
||||
if p.is_empty() {
|
||||
return if start <= t.len() { Some(start) } else { None };
|
||||
}
|
||||
(start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some()))
|
||||
// The first token that consumes a character anchors the scan: a literal skips straight
|
||||
// to its occurrences, and after leading `?`s each attempt only pays for the `?` run and
|
||||
// one suffix try. Every attempt shares one budget, so the total work stays flat.
|
||||
let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count();
|
||||
let anchor = p.get(anys);
|
||||
let mut budget = MAX_STEPS;
|
||||
let mut i = start;
|
||||
while i < t.len() {
|
||||
let hit = match (anchor, t.get(i + anys)) {
|
||||
(Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||
(Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead
|
||||
_ => match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||
};
|
||||
if hit {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
||||
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively.
|
||||
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively. Numbers
|
||||
/// compare exactly, not at the 15 digits of `=` and the criteria functions (Excel 365:
|
||||
/// `MATCH(0.1+0.2,{0.3},0)` is #N/A while `0.1+0.2=0.3` is TRUE).
|
||||
pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
|
||||
match (needle, hay) {
|
||||
(Value::Text(p), Value::Text(t)) => {
|
||||
@@ -324,7 +372,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
|
||||
compare_text(p, t) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
|
||||
(Value::Number(a), Value::Number(b)) => a == b,
|
||||
(Value::Bool(a), Value::Bool(b)) => a == b,
|
||||
(Value::Error(a), Value::Error(b)) => a == b,
|
||||
(Value::Empty, Value::Empty) => true,
|
||||
@@ -370,7 +418,8 @@ mod tests {
|
||||
assert!(m("#N/A", Value::Error(CellError::NA)));
|
||||
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
|
||||
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
|
||||
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
|
||||
assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
|
||||
assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false));
|
||||
assert!(m("<>a*", Value::from("bcd")));
|
||||
assert!(m(">1/1/2020", Value::Number(44000.0)));
|
||||
// Text patterns never match blank cells, but an empty string is text.
|
||||
|
||||
@@ -742,7 +742,7 @@ fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R<Cx>) -> R<Value> {
|
||||
fmt_complex(f(x, y)?, s.unwrap_or('i'))
|
||||
}
|
||||
fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
im_binary(a, |x, y| x.div(y))
|
||||
im_binary(a, Cx::div)
|
||||
}
|
||||
fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
im_binary(a, |x, y| Ok(x.sub(y)))
|
||||
|
||||
@@ -278,7 +278,7 @@ fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option<f64> {
|
||||
Aggregate::Max => xs.iter().copied().reduce(f64::max),
|
||||
Aggregate::Min => xs.iter().copied().reduce(f64::min),
|
||||
Aggregate::Median => {
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let k = xs.len() / 2;
|
||||
let hi = xs.get(k).copied()?;
|
||||
if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
|
||||
@@ -415,7 +415,7 @@ impl Timeline {
|
||||
return None;
|
||||
}
|
||||
let mut sorted = times.to_vec();
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
sorted.dedup();
|
||||
let axis = detect_axis(&sorted, sys).ok()?;
|
||||
let last = axis.position(*sorted.last()?)?.round();
|
||||
|
||||
@@ -13,12 +13,32 @@ use crate::{Arg, Ctx, FnSpec, VAR};
|
||||
// Time value of money
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from
|
||||
/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to
|
||||
/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan
|
||||
/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a
|
||||
/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows.
|
||||
fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 {
|
||||
if rate == 0.0 {
|
||||
return -(pv + fv) / nper;
|
||||
}
|
||||
let p = (1.0 + rate).powf(nper);
|
||||
-(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0))
|
||||
if rate <= -1.0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let l = nper * rate.ln_1p();
|
||||
let m = l.exp_m1();
|
||||
let k = 1.0 + rate * typ;
|
||||
if m > 0.0 {
|
||||
// (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1.
|
||||
// rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow.
|
||||
-(pv * rate + (pv + fv) * (rate / m)) / k
|
||||
} else if m > -0.5 {
|
||||
// p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop.
|
||||
-(rate * (pv + fv + pv * m)) / (k * m)
|
||||
} else {
|
||||
// p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself.
|
||||
-(rate * (fv + pv * l.exp())) / (k * m)
|
||||
}
|
||||
}
|
||||
|
||||
fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 {
|
||||
@@ -1790,6 +1810,42 @@ mod tests {
|
||||
is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
/// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across
|
||||
/// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`.
|
||||
#[test]
|
||||
fn pmt_matches_excel_within_a_few_ulps() {
|
||||
for (rate, nper, pv, fv, typ, excel) in [
|
||||
(0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904),
|
||||
(0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778),
|
||||
(0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775),
|
||||
(0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324),
|
||||
(0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787),
|
||||
(-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645),
|
||||
] {
|
||||
let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]);
|
||||
close_tol(got, excel, 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a
|
||||
/// tiny rate no longer loses its low digits in 1 + rate.
|
||||
#[test]
|
||||
fn pmt_edge_cases_follow_excel() {
|
||||
is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num);
|
||||
is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num);
|
||||
is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num);
|
||||
assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0));
|
||||
assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0));
|
||||
assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0));
|
||||
close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15);
|
||||
close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9);
|
||||
close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6);
|
||||
close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flows() {
|
||||
close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9);
|
||||
|
||||
@@ -30,6 +30,7 @@ macro_rules! f {
|
||||
category: $crate::Category::$cat,
|
||||
volatile: true,
|
||||
scalar_args: $scal,
|
||||
value_args: &[],
|
||||
signature: $sig,
|
||||
description: $desc,
|
||||
imp: {
|
||||
@@ -48,6 +49,7 @@ macro_rules! f {
|
||||
category: $crate::Category::$cat,
|
||||
volatile: false,
|
||||
scalar_args: $scal,
|
||||
value_args: &[],
|
||||
signature: $sig,
|
||||
description: $desc,
|
||||
imp: {
|
||||
@@ -156,6 +158,11 @@ pub struct FnSpec {
|
||||
/// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an
|
||||
/// empty slice means no lifting.
|
||||
pub scalar_args: &'static [bool],
|
||||
/// Parameter positions that take a single value and reject a reference to more than one
|
||||
/// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…),
|
||||
/// where other scalar parameters lift the range. Arrays that aren't references still lift.
|
||||
/// Same layout as `scalar_args`; filled at registration from [`lift::value_args`].
|
||||
pub value_args: &'static [bool],
|
||||
pub signature: &'static str,
|
||||
pub description: &'static str,
|
||||
pub imp: FnImpl,
|
||||
@@ -176,6 +183,14 @@ impl FnSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`.
|
||||
pub fn is_value_arg(&self, i: usize) -> bool {
|
||||
match self.value_args.get(i) {
|
||||
Some(b) => *b,
|
||||
None => self.value_args.last().copied().unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a call with `n` arguments is valid (Excel allows at most 255).
|
||||
pub fn accepts(&self, n: usize) -> bool {
|
||||
(self.min_args..=self.max_args).contains(&n) && n <= 255
|
||||
@@ -186,7 +201,7 @@ impl FnSpec {
|
||||
pub(crate) const VAR: usize = usize::MAX;
|
||||
|
||||
fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&'static str, usize>)> = OnceLock::new();
|
||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&str, usize>)> = OnceLock::new();
|
||||
REG.get_or_init(|| {
|
||||
let mut v: Vec<FnSpec> = Vec::new();
|
||||
for list in [
|
||||
@@ -204,6 +219,9 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
] {
|
||||
v.extend(list);
|
||||
}
|
||||
for s in &mut v {
|
||||
s.value_args = lift::value_args(s.name);
|
||||
}
|
||||
let mut map = HashMap::with_capacity(v.len());
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
map.entry(s.name).or_insert(i);
|
||||
|
||||
@@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value};
|
||||
use crate::util::MAX_CELLS;
|
||||
use crate::{Arg, Ctx, FnSpec};
|
||||
|
||||
/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more
|
||||
/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no
|
||||
/// implicit intersection), while array constants and computed arrays lift. Range parameters
|
||||
/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend
|
||||
/// (which lifts a range) are `false`.
|
||||
pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[
|
||||
// Date and time.
|
||||
("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]),
|
||||
("WORKDAY NETWORKDAYS", &[true, true, false]),
|
||||
("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]),
|
||||
// Financial.
|
||||
(
|
||||
"ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \
|
||||
DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \
|
||||
PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT",
|
||||
&[true],
|
||||
),
|
||||
("FVSCHEDULE XNPV", &[true, false]),
|
||||
("XIRR", &[false, false, true]),
|
||||
// Math and information.
|
||||
("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]),
|
||||
("SERIESSUM", &[true, true, true, false]),
|
||||
// Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here).
|
||||
(
|
||||
"BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \
|
||||
ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \
|
||||
IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \
|
||||
IMTAN",
|
||||
&[true],
|
||||
),
|
||||
];
|
||||
|
||||
/// The value-parameter mask of `name` (empty for functions without value parameters).
|
||||
pub(crate) fn value_args(name: &str) -> &'static [bool] {
|
||||
VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask)
|
||||
}
|
||||
|
||||
/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds
|
||||
/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel.
|
||||
fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
|
||||
let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1);
|
||||
if !args.iter().enumerate().any(|(i, a)| multi(i, a)) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
args.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
|
||||
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
|
||||
let args = rejected.as_deref().unwrap_or(args);
|
||||
let mut rows = 0usize;
|
||||
let mut cols = 0usize;
|
||||
let mut any = false;
|
||||
@@ -94,4 +148,37 @@ mod tests {
|
||||
);
|
||||
is_err(huge, CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_params_reject_multi_cell_references() {
|
||||
let s = FnSpec { value_args: &[true, false], ..spec() };
|
||||
let mut ctx = TestCtx::default();
|
||||
// A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine.
|
||||
is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value);
|
||||
assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0));
|
||||
assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]);
|
||||
// The rejected reference adds no shape: the other argument still lifts.
|
||||
let v = Value::Error(CellError::Value);
|
||||
assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]);
|
||||
// A position that isn't a value parameter still lifts the reference.
|
||||
assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_arg_table_names_registered_scalar_positions() {
|
||||
for (names, mask) in super::VALUE_ARGS {
|
||||
assert!(!mask.is_empty());
|
||||
for name in names.split_ascii_whitespace() {
|
||||
let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") };
|
||||
assert_eq!(s.value_args, *mask, "{name}");
|
||||
for i in 0..s.max_args.min(12) {
|
||||
assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2)));
|
||||
assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2)));
|
||||
assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty()));
|
||||
assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Order of two values for the approximate and binary searches: [`compare`], except that numbers
|
||||
/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its
|
||||
/// lookups don't. Observed in Excel 365 with A1 holding
|
||||
/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because
|
||||
/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4.
|
||||
/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode.
|
||||
fn lookup_order(a: &Value, b: &Value) -> Ordering {
|
||||
match (a, b) {
|
||||
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or_else(|| compare(a, b)),
|
||||
_ => compare(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn same_type(a: &Value, b: &Value) -> bool {
|
||||
matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_)))
|
||||
}
|
||||
@@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
continue;
|
||||
};
|
||||
let v = hay.get(j)?;
|
||||
let o = compare(v, x);
|
||||
let o = lookup_order(v, x);
|
||||
if o == Ordering::Equal {
|
||||
let mut k = j;
|
||||
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
|
||||
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) {
|
||||
k += 1;
|
||||
}
|
||||
return Some(k);
|
||||
@@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
|
||||
match v {
|
||||
Value::Empty => Ordering::Greater,
|
||||
_ if desc => compare(v, x).reverse(),
|
||||
_ => compare(v, x),
|
||||
_ if desc => lookup_order(v, x).reverse(),
|
||||
_ => lookup_order(v, x),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +275,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
|
||||
if !same_type(x, v) {
|
||||
continue;
|
||||
}
|
||||
let o = compare(v, x);
|
||||
let o = lookup_order(v, x);
|
||||
let candidate = if match_mode == -1 { o == Ordering::Less } else { o == Ordering::Greater };
|
||||
if !candidate {
|
||||
continue;
|
||||
@@ -270,7 +283,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
|
||||
let better = match best.and_then(|b| hay.get(b)) {
|
||||
None => true,
|
||||
Some(bv) => {
|
||||
let c = compare(v, bv);
|
||||
let c = lookup_order(v, bv);
|
||||
if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less }
|
||||
}
|
||||
};
|
||||
@@ -748,6 +761,44 @@ mod tests {
|
||||
assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]);
|
||||
}
|
||||
|
||||
/// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365.
|
||||
#[test]
|
||||
fn lookups_compare_numbers_exactly() {
|
||||
let e = 0.1 + 0.2;
|
||||
let one = || rf(col(&[e]));
|
||||
// Exact modes miss a key one bit away.
|
||||
is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA);
|
||||
is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA);
|
||||
is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA);
|
||||
close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0);
|
||||
close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0);
|
||||
is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA);
|
||||
// 0 and -0 are still the same key.
|
||||
close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0);
|
||||
// Approximate modes: 0.3 sorts below 0.30000000000000004.
|
||||
is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA);
|
||||
close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0);
|
||||
is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA);
|
||||
is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA);
|
||||
close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0);
|
||||
// The bisection stops on 0.3 and doesn't run on into the next entry.
|
||||
let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4]));
|
||||
close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0);
|
||||
close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0);
|
||||
close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0);
|
||||
close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0);
|
||||
let asc = || rf(col(&[0.1, e, 0.5]));
|
||||
let desc = || rf(col(&[0.5, e, 0.1]));
|
||||
close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0);
|
||||
close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0);
|
||||
is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0);
|
||||
close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0);
|
||||
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index() {
|
||||
let t3 = table_rows3();
|
||||
|
||||
@@ -43,7 +43,7 @@ fn var_p(x: &[f64]) -> R<f64> {
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,8 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if seen_dec {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
} else if c == '.' || c == ',' {
|
||||
return Err(CellError::Value);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
@@ -521,7 +523,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
let mut j = t.len();
|
||||
while j > 0 {
|
||||
let best =
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((j - l, j));
|
||||
@@ -535,7 +537,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < t.len() {
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((i, i + l));
|
||||
@@ -586,7 +588,7 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
|
||||
if delims.iter().all(|d| d.is_empty()) {
|
||||
if delims.iter().all(Vec::is_empty) {
|
||||
return vec![s.to_vec()];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
@@ -746,9 +748,9 @@ fn convert_replacement(r: &str) -> String {
|
||||
let mut i = 0;
|
||||
while i < c.len() {
|
||||
let ch = c[i];
|
||||
if ch == '$' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit()) {
|
||||
if ch == '$' && c.get(i + 1).is_some_and(char::is_ascii_digit) {
|
||||
let mut j = i + 1;
|
||||
while c.get(j).is_some_and(|d| d.is_ascii_digit()) {
|
||||
while c.get(j).is_some_and(char::is_ascii_digit) {
|
||||
j += 1;
|
||||
}
|
||||
out.push_str("${");
|
||||
@@ -1211,11 +1213,20 @@ mod tests {
|
||||
close(ev("VALUE", vec![t("12:00")]), 0.5);
|
||||
close(ev("VALUE", vec![t("2020-01-01")]), 43831.0);
|
||||
is_err(ev("VALUE", vec![t("abc")]), CellError::Value);
|
||||
// Accounting negatives, as Excel 365 reads them.
|
||||
close(ev("VALUE", vec![t("$(5)")]), -5.0);
|
||||
close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5);
|
||||
close(ev("VALUE", vec![t("(5)%")]), -0.05);
|
||||
for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] {
|
||||
is_err(ev("VALUE", vec![t(s)]), CellError::Value);
|
||||
}
|
||||
close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27);
|
||||
close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035);
|
||||
close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0);
|
||||
close(ev("NUMBERVALUE", vec![t("")]), 0.0);
|
||||
is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value);
|
||||
is_err(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), CellError::Value);
|
||||
is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(" ")]), CellError::Value);
|
||||
is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6");
|
||||
is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230");
|
||||
is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6");
|
||||
|
||||
@@ -410,6 +410,7 @@ pub enum ChartKind {
|
||||
Doughnut,
|
||||
Area,
|
||||
AreaStacked,
|
||||
AreaStacked100,
|
||||
Scatter,
|
||||
ScatterLines,
|
||||
Bubble,
|
||||
@@ -1044,6 +1045,9 @@ pub struct PivotTable {
|
||||
pub values: Vec<PivotValue>,
|
||||
pub filters: Vec<PivotFilter>,
|
||||
pub layout: PivotLayout,
|
||||
/// PivotTable Options › Data › Enable show details: Show Details lists a value cell's
|
||||
/// source records only while this is on.
|
||||
pub enable_show_details: bool,
|
||||
/// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows".
|
||||
pub grand_totals_rows: bool,
|
||||
/// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns".
|
||||
@@ -1087,6 +1091,7 @@ impl Default for PivotTable {
|
||||
values: vec![],
|
||||
filters: vec![],
|
||||
layout: PivotLayout::Compact,
|
||||
enable_show_details: true,
|
||||
grand_totals_rows: true,
|
||||
grand_totals_cols: true,
|
||||
subtotals: true,
|
||||
|
||||
@@ -7,7 +7,10 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::{Cell, CellPicture};
|
||||
use crate::features::*;
|
||||
use crate::features::{
|
||||
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
|
||||
Validation,
|
||||
};
|
||||
use crate::store::CellStore;
|
||||
use crate::style::{Color, StyleId};
|
||||
|
||||
@@ -80,6 +83,13 @@ pub struct Sheet {
|
||||
/// outline itself stays.
|
||||
#[serde(default = "yes")]
|
||||
pub show_outline_symbols: bool,
|
||||
/// Outline summary rows sit below their detail, and summary columns right of it (Data ▸
|
||||
/// Outline ▸ Settings; off puts them above / left). Saved as `outlinePr summaryBelow` and
|
||||
/// `summaryRight`.
|
||||
#[serde(default = "yes")]
|
||||
pub summary_below: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub summary_right: bool,
|
||||
pub right_to_left: bool,
|
||||
pub zoom: u16,
|
||||
pub cond_formats: Vec<CondFormat>,
|
||||
@@ -149,6 +159,8 @@ impl Sheet {
|
||||
visibility: Visibility::Visible,
|
||||
show_gridlines: true,
|
||||
show_outline_symbols: true,
|
||||
summary_below: true,
|
||||
summary_right: true,
|
||||
show_headings: true,
|
||||
show_formulas: false,
|
||||
show_zeros: true,
|
||||
@@ -613,8 +625,11 @@ mod tests {
|
||||
|
||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||
mod picture_map {
|
||||
use super::*;
|
||||
use serde::{Deserializer, Serializer};
|
||||
use crate::cell::CellPicture;
|
||||
use gridcraft_core::CellRef;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
map.iter().collect::<Vec<_>>().serialize(serializer)
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
let s = if k + 1 == count {
|
||||
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
dc.get(k).map(|c| c.to_string()).unwrap_or_default()
|
||||
dc.get(k).map(ToString::to_string).unwrap_or_default()
|
||||
};
|
||||
let s = if s.is_empty() {
|
||||
match toks.get(ti) {
|
||||
|
||||
@@ -99,8 +99,8 @@ pub(crate) fn fold(c: char) -> Option<char> {
|
||||
'ž' => 'z',
|
||||
'Œ' => 'W',
|
||||
'œ' => 'm',
|
||||
'‘' | '’' | '‚' => '\'',
|
||||
'“' | '”' | '„' => '"',
|
||||
'‘'..='‚' => '\'',
|
||||
'“'..='„' => '"',
|
||||
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
|
||||
'\u{a0}' => ' ',
|
||||
'·' | '•' => '.',
|
||||
|
||||
@@ -352,8 +352,8 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
/// File name and folder of a recent path, split as text.
|
||||
fn split_path(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
||||
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string();
|
||||
(name, folder)
|
||||
}
|
||||
|
||||
@@ -888,7 +888,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
|
||||
_ => return false,
|
||||
};
|
||||
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
||||
if let Some(pick) = &app.services.pick_save {
|
||||
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
|
||||
// The picker may suggest a workbook extension; the export writes the chosen format.
|
||||
|
||||
@@ -130,7 +130,12 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
|
||||
painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
} else {
|
||||
crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130));
|
||||
crate::icons::paint_on_sheet(
|
||||
&painter,
|
||||
cell.shrink(2.0),
|
||||
crate::icons::Icon::Picture,
|
||||
crate::theme::Tokens::get(painter.ctx()).sheet_control_border,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook};
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::grid::Geo;
|
||||
use crate::theme;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
fn rgba(c: [u8; 4]) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
|
||||
@@ -310,7 +310,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
}
|
||||
ShapeKind::Icon => {
|
||||
if let Some(icon) = crate::icons::from_name(&sp.text) {
|
||||
crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill });
|
||||
let t = Tokens::get(p.ctx());
|
||||
crate::icons::paint_on_sheet(p, r, icon, if fill == Color32::TRANSPARENT { t.cell_text } else { fill });
|
||||
}
|
||||
label = false;
|
||||
}
|
||||
@@ -341,7 +342,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) {
|
||||
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
} else {
|
||||
p.rect_filled(r, 0.0, Color32::from_gray(230));
|
||||
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track);
|
||||
}
|
||||
if app.selected_chart == Some(im.id) {
|
||||
selection_frame(p, r);
|
||||
@@ -356,7 +357,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
let start = p.add(egui::Shape::Noop);
|
||||
// Soft shadow + white card.
|
||||
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let m = EguiMeasure { painter: p, z: geo.z };
|
||||
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
|
||||
@@ -373,9 +374,10 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
|
||||
{
|
||||
let rr = geo.range_rect(sh, rf.range());
|
||||
let color = Tokens::get(p.ctx()).ref_color(0, true);
|
||||
for pane in geo.range_panes(rf.range()) {
|
||||
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
|
||||
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside);
|
||||
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +449,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz
|
||||
if !r.contains(tr.left_top()) {
|
||||
continue; // overflowed the card; skip (no scroll yet)
|
||||
}
|
||||
let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) };
|
||||
let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) };
|
||||
p.rect_filled(*tr, 3.0 * geo.z, fill);
|
||||
let cp = p.with_clip_rect(tr.intersect(p.clip_rect()));
|
||||
cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg);
|
||||
@@ -491,10 +493,11 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) ->
|
||||
}
|
||||
|
||||
fn selection_frame(p: &Painter, r: Rect) {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside);
|
||||
let t = Tokens::get(p.ctx());
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside);
|
||||
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
|
||||
p.circle_filled(c, 4.0, Color32::WHITE);
|
||||
p.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::from_gray(100)));
|
||||
p.circle_filled(c, 4.0, t.sheet_control_bg);
|
||||
p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border));
|
||||
}
|
||||
let _ = Align2::CENTER_CENTER;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
//! A small code editor: monospace text with syntax colours, Tab to indent, Enter that keeps the
|
||||
//! indentation, and a scroll area that follows the caret. It colours Python (for code in cells,
|
||||
//! where `xl()` reads the sheet) and LaTeX (for equation source); reached as
|
||||
//! `widgets::code_editor`. The tokenizer and the key edits are pure functions, tested below.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
use egui::{FontFamily, FontId, Key, Response, TextEdit, Ui};
|
||||
|
||||
use crate::theme::{self, Syntax, Tokens};
|
||||
|
||||
/// The language a code editor colours.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Lang {
|
||||
Python,
|
||||
Latex,
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// A coloured span's kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Token {
|
||||
Keyword,
|
||||
String,
|
||||
Number,
|
||||
Comment,
|
||||
Builtin,
|
||||
}
|
||||
|
||||
/// Spaces one Tab inserts.
|
||||
pub const INDENT: &str = " ";
|
||||
|
||||
const PY_KEYWORDS: &[&str] = &[
|
||||
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except",
|
||||
"finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "match", "case", "nonlocal", "not", "or", "pass", "raise", "return",
|
||||
"try", "while", "with", "yield",
|
||||
];
|
||||
|
||||
const PY_BUILTINS: &[&str] = &[
|
||||
"abs",
|
||||
"all",
|
||||
"any",
|
||||
"bool",
|
||||
"dict",
|
||||
"enumerate",
|
||||
"filter",
|
||||
"float",
|
||||
"format",
|
||||
"int",
|
||||
"isinstance",
|
||||
"len",
|
||||
"list",
|
||||
"map",
|
||||
"max",
|
||||
"min",
|
||||
"print",
|
||||
"range",
|
||||
"reversed",
|
||||
"round",
|
||||
"set",
|
||||
"sorted",
|
||||
"str",
|
||||
"sum",
|
||||
"tuple",
|
||||
"type",
|
||||
"zip",
|
||||
"xl",
|
||||
];
|
||||
|
||||
/// The coloured spans of `text` (byte ranges on char boundaries, in order, not overlapping).
|
||||
/// Anything not covered is plain text. Never panics, whatever the input.
|
||||
pub fn highlight(text: &str, lang: Lang) -> Vec<(Range<usize>, Token)> {
|
||||
match lang {
|
||||
Lang::Python => python(text),
|
||||
Lang::Latex => latex(text),
|
||||
Lang::Plain => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn python(text: &str) -> Vec<(Range<usize>, Token)> {
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&(start, c)) = chars.get(i) {
|
||||
if c == '#' {
|
||||
let mut j = i;
|
||||
while chars.get(j).is_some_and(|(_, c)| *c != '\n') {
|
||||
j += 1;
|
||||
}
|
||||
out.push((start..end_of(j), Token::Comment));
|
||||
i = j;
|
||||
} else if c == '"' || c == '\'' {
|
||||
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
|
||||
let mut j = if triple { i + 3 } else { i + 1 };
|
||||
loop {
|
||||
match chars.get(j) {
|
||||
None => break,
|
||||
Some((_, '\\')) => j += 2,
|
||||
Some((_, '\n')) if !triple => break,
|
||||
Some((_, d)) if *d == c => {
|
||||
if !triple {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
|
||||
j += 3;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
Some(_) => j += 1,
|
||||
}
|
||||
}
|
||||
let j = j.min(chars.len());
|
||||
out.push((start..end_of(j), Token::String));
|
||||
i = j;
|
||||
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) {
|
||||
let mut j = i + 1;
|
||||
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphanumeric() || *d == '_' || *d == '.') {
|
||||
j += 1;
|
||||
}
|
||||
out.push((start..end_of(j), Token::Number));
|
||||
i = j;
|
||||
} else if c.is_alphabetic() || c == '_' {
|
||||
let mut j = i + 1;
|
||||
while chars.get(j).is_some_and(|(_, d)| d.is_alphanumeric() || *d == '_') {
|
||||
j += 1;
|
||||
}
|
||||
let word = text.get(start..end_of(j)).unwrap_or("");
|
||||
if PY_KEYWORDS.contains(&word) {
|
||||
out.push((start..end_of(j), Token::Keyword));
|
||||
} else if PY_BUILTINS.contains(&word) {
|
||||
out.push((start..end_of(j), Token::Builtin));
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn latex(text: &str) -> Vec<(Range<usize>, Token)> {
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&(start, c)) = chars.get(i) {
|
||||
match c {
|
||||
'%' => {
|
||||
let mut j = i;
|
||||
while chars.get(j).is_some_and(|(_, c)| *c != '\n') {
|
||||
j += 1;
|
||||
}
|
||||
out.push((start..end_of(j), Token::Comment));
|
||||
i = j;
|
||||
}
|
||||
'\\' => {
|
||||
let mut j = i + 1;
|
||||
if chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) {
|
||||
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) {
|
||||
j += 1;
|
||||
}
|
||||
} else if chars.get(j).is_some() {
|
||||
j += 1; // a control symbol such as \{ or \%
|
||||
}
|
||||
out.push((start..end_of(j), Token::Keyword));
|
||||
i = j;
|
||||
}
|
||||
'^' | '_' | '&' => {
|
||||
out.push((start..end_of(i + 1), Token::Builtin));
|
||||
i += 1;
|
||||
}
|
||||
d if d.is_ascii_digit() => {
|
||||
let mut j = i + 1;
|
||||
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_digit() || *d == '.') {
|
||||
j += 1;
|
||||
}
|
||||
out.push((start..end_of(j), Token::Number));
|
||||
i = j;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn color(s: &Syntax, token: Token) -> egui::Color32 {
|
||||
match token {
|
||||
Token::Keyword => s.keyword,
|
||||
Token::String => s.string,
|
||||
Token::Number => s.number,
|
||||
Token::Comment => s.comment,
|
||||
Token::Builtin => s.builtin,
|
||||
}
|
||||
}
|
||||
|
||||
/// The code editor's font: the monospace face at 12.5 px.
|
||||
pub fn font() -> FontId {
|
||||
FontId::new(12.5, FontFamily::Name(theme::MONO.into()))
|
||||
}
|
||||
|
||||
/// A layout job for `text` with syntax colours.
|
||||
pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, syntax: &Syntax, wrap_width: f32) -> egui::text::LayoutJob {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let mut pos = 0usize;
|
||||
for (range, token) in highlight(text, lang) {
|
||||
if range.start < pos {
|
||||
continue;
|
||||
}
|
||||
if let (Some(before), Some(span)) = (text.get(pos..range.start), text.get(range.clone())) {
|
||||
job.append(before, 0.0, egui::TextFormat::simple(font.clone(), plain));
|
||||
job.append(span, 0.0, egui::TextFormat::simple(font.clone(), color(syntax, token)));
|
||||
pos = range.end;
|
||||
}
|
||||
}
|
||||
job.append(text.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font.clone(), plain));
|
||||
job.wrap.max_width = wrap_width;
|
||||
job
|
||||
}
|
||||
|
||||
fn byte_of(text: &str, char_index: usize) -> usize {
|
||||
text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len())
|
||||
}
|
||||
|
||||
/// Start (char index) of the line holding char `at`.
|
||||
fn line_start(text: &str, at: usize) -> usize {
|
||||
let b = byte_of(text, at);
|
||||
text.get(..b).and_then(|s| s.rfind('\n')).map(|nl| text.get(..nl + 1).map(|s| s.chars().count()).unwrap_or(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// What a key did to the text: the new caret (and selection anchor), as char indices.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Edit {
|
||||
pub anchor: usize,
|
||||
pub caret: usize,
|
||||
}
|
||||
|
||||
/// Enter: a newline that keeps the current line's indentation (one level more after a Python `:`).
|
||||
/// `sel` is the selection as char indices (anchor, caret); it is replaced.
|
||||
pub fn newline(text: &mut String, sel: (usize, usize), lang: Lang) -> Edit {
|
||||
let count = text.chars().count();
|
||||
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
||||
let ls = line_start(text, a);
|
||||
let line_before: String = text.chars().skip(ls).take(a - ls.min(a)).collect();
|
||||
let mut indent: String = line_before.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
|
||||
if lang == Lang::Python && line_before.trim_end().ends_with(':') {
|
||||
indent.push_str(INDENT);
|
||||
}
|
||||
let insert = format!("\n{indent}");
|
||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
||||
text.replace_range(ba..bb, &insert);
|
||||
let caret = a + insert.chars().count();
|
||||
Edit { anchor: caret, caret }
|
||||
}
|
||||
|
||||
/// Typed text (or a paste) replacing the selection.
|
||||
pub fn insert(text: &mut String, sel: (usize, usize), s: &str) -> Edit {
|
||||
let count = text.chars().count();
|
||||
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
||||
text.replace_range(ba..bb, s);
|
||||
let caret = a + s.chars().count();
|
||||
Edit { anchor: caret, caret }
|
||||
}
|
||||
|
||||
/// Backspace (`back`) or Delete: the selection goes, or the character before or after the caret.
|
||||
pub fn delete(text: &mut String, sel: (usize, usize), back: bool) -> Edit {
|
||||
let count = text.chars().count();
|
||||
let (mut a, mut b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
||||
if a == b {
|
||||
if back {
|
||||
a = a.saturating_sub(1);
|
||||
} else {
|
||||
b = (b + 1).min(count);
|
||||
}
|
||||
}
|
||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
||||
text.replace_range(ba..bb, "");
|
||||
Edit { anchor: a, caret: a }
|
||||
}
|
||||
|
||||
/// An edit the editor makes itself, read off an input event by `classify`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EditKey<'a> {
|
||||
Insert(&'a str),
|
||||
Enter,
|
||||
Tab { dedent: bool },
|
||||
Backspace,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// What one input event means to the editor. See `apply_in_order`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Class<'a> {
|
||||
/// The editor applies it: typed text, a paste, Enter, Tab, Shift+Tab, Backspace, Delete.
|
||||
Edit(EditKey<'a>),
|
||||
/// The editor takes it off the queue and does nothing: Tab under a command or option
|
||||
/// modifier, which the field would turn into a tab character in place of the selection.
|
||||
Dropped,
|
||||
/// The text field acts on it: a caret or selection key, a command shortcut (select all,
|
||||
/// undo, word deletion), IME, Copy, Cut, or a click, which the field reads from the pointer
|
||||
/// state.
|
||||
Field,
|
||||
/// Neither reads it from the queue: a key release, a printable key press (its character
|
||||
/// arrives as the `Text` event after it, also under Shift or Option), a pointer move, Escape
|
||||
/// (the focus system acts on it when the pass begins).
|
||||
Other,
|
||||
}
|
||||
|
||||
fn classify(ev: &egui::Event) -> Class<'_> {
|
||||
use egui::Event;
|
||||
match ev {
|
||||
// The field treats a lone newline as the Enter key's business, and ignores it.
|
||||
Event::Text(t) if t != "\n" && t != "\r" => Class::Edit(EditKey::Insert(t)),
|
||||
Event::Text(_) => Class::Other,
|
||||
Event::Paste(t) => Class::Edit(EditKey::Insert(t)),
|
||||
Event::Key { key, pressed: true, modifiers, .. } => {
|
||||
let command = modifiers.command || modifiers.ctrl || modifiers.mac_cmd;
|
||||
match key {
|
||||
// The field makes a newline of Enter under any modifier but a command one, and
|
||||
// a tab character of Tab under every modifier; the editor takes all of those.
|
||||
Key::Enter if !command => Class::Edit(EditKey::Enter),
|
||||
Key::Tab if !command && !modifiers.alt => Class::Edit(EditKey::Tab { dedent: modifiers.shift }),
|
||||
Key::Tab => Class::Dropped,
|
||||
Key::Backspace if modifiers.is_none() => Class::Edit(EditKey::Backspace),
|
||||
Key::Delete if modifiers.is_none() => Class::Edit(EditKey::Delete),
|
||||
// The keys egui's text field reads: caret and selection moves, word and line
|
||||
// deletion, and its command shortcuts (select all, undo, redo, the Emacs keys).
|
||||
Key::Enter | Key::Backspace | Key::Delete => Class::Field,
|
||||
Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => Class::Field,
|
||||
_ if command => Class::Field,
|
||||
_ => Class::Other,
|
||||
}
|
||||
}
|
||||
Event::Copy | Event::Cut | Event::Ime(_) | Event::PointerButton { pressed: true, .. } => Class::Field,
|
||||
_ => Class::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an event is part of the keyboard stream, whose order the editor keeps. Pointer,
|
||||
/// touch, scroll and window events are not: egui folds them into its input state when the
|
||||
/// pass begins, so they act in the pass they arrive in whatever their place among the keys.
|
||||
fn is_keyboard(ev: &egui::Event) -> bool {
|
||||
use egui::Event;
|
||||
matches!(ev, Event::Key { .. } | Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_))
|
||||
}
|
||||
|
||||
fn apply(key: EditKey<'_>, text: &mut String, sel: (usize, usize), lang: Lang) -> Edit {
|
||||
match key {
|
||||
EditKey::Insert(s) => insert(text, sel, s),
|
||||
EditKey::Enter => newline(text, sel, lang),
|
||||
EditKey::Tab { dedent } => indent(text, sel, dedent),
|
||||
EditKey::Backspace => delete(text, sel, true),
|
||||
EditKey::Delete => delete(text, sel, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `apply_in_order` did with a pass's events.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Applied {
|
||||
/// Whether the text or the selection changed.
|
||||
pub edited: bool,
|
||||
/// The keyboard events held back for the next pass, in order: the editor's first key after
|
||||
/// an event the text field acts on, and every keyboard event after that key.
|
||||
pub held: Vec<egui::Event>,
|
||||
}
|
||||
|
||||
/// Applies the pass's input to `text` in the order it came. A pass can carry several events
|
||||
/// (typing between repaints, key repeat), and two readers take them: this editor, for typed
|
||||
/// text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete, and then egui's text field, for
|
||||
/// the caret and selection keys, command shortcuts, IME and clicks. The editor runs first, so
|
||||
/// it can only apply its keys up to the first event the field acts on: its edits before that
|
||||
/// event are applied and taken from the queue, the field's event and the run after it stay in
|
||||
/// the queue for the field, and from the editor's next key on the keyboard events are held
|
||||
/// back (`Applied::held`) for the next pass, where `code_editor` puts them ahead of that
|
||||
/// pass's input and they go through this again. So select all, Tab in one pass indents every
|
||||
/// line in two passes, and the field never gets an Enter or a Tab of a focused editor.
|
||||
/// Printable key presses do not split the stream: the field reads the `Text` event that
|
||||
/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret),
|
||||
/// updated.
|
||||
pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied {
|
||||
let mut out = Applied::default();
|
||||
// An event the field acts on has gone by: the editor's keys after it wait for the next pass.
|
||||
let mut field_seen = false;
|
||||
// A key is waiting: every keyboard event after it waits too, so the order holds.
|
||||
let mut holding = false;
|
||||
let mut keep = Vec::with_capacity(events.len());
|
||||
for ev in std::mem::take(events) {
|
||||
if holding && is_keyboard(&ev) {
|
||||
out.held.push(ev);
|
||||
continue;
|
||||
}
|
||||
match classify(&ev) {
|
||||
Class::Edit(_) if field_seen => {
|
||||
holding = true;
|
||||
out.held.push(ev);
|
||||
}
|
||||
Class::Edit(key) => {
|
||||
let e = apply(key, text, *sel, lang);
|
||||
*sel = (e.anchor, e.caret);
|
||||
out.edited = true;
|
||||
}
|
||||
Class::Dropped => {}
|
||||
Class::Field => {
|
||||
field_seen = true;
|
||||
keep.push(ev);
|
||||
}
|
||||
Class::Other => keep.push(ev),
|
||||
}
|
||||
}
|
||||
*events = keep;
|
||||
out
|
||||
}
|
||||
|
||||
/// The keyboard events a code editor held back, with the pass that held them.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Held {
|
||||
pass: u64,
|
||||
events: Vec<egui::Event>,
|
||||
}
|
||||
|
||||
fn held_id(id: egui::Id) -> egui::Id {
|
||||
id.with("held")
|
||||
}
|
||||
|
||||
/// The events the editor `id` held back in the previous pass, to go ahead of this pass's input
|
||||
/// (see `apply_in_order`). Only the editor they were typed into gets them, only while it still
|
||||
/// has the focus, and only in the pass right after: once the focus has moved on, or the editor
|
||||
/// was not shown in between, they are dropped rather than typed into another widget.
|
||||
fn take_held(ui: &Ui, id: egui::Id, focused: bool) -> Vec<egui::Event> {
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.ctx()
|
||||
.data_mut(|d| d.remove_temp::<Held>(held_id(id)))
|
||||
.filter(|h| focused && h.pass.checked_add(1) == Some(pass))
|
||||
.map(|h| h.events)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Holds `events` back for the editor's next pass, and asks for that pass.
|
||||
fn hold(ui: &Ui, id: egui::Id, events: Vec<egui::Event>) {
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(held_id(id), Held { pass, events }));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
/// Tab (indent) or Shift+Tab (dedent). A selection over several lines indents or dedents each
|
||||
/// line; otherwise Tab inserts four spaces at the caret.
|
||||
pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit {
|
||||
let count = text.chars().count();
|
||||
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
||||
let multi = text.chars().skip(a).take(b - a).any(|c| c == '\n');
|
||||
if !dedent && !multi {
|
||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
||||
text.replace_range(ba..bb, INDENT);
|
||||
let caret = a + INDENT.len();
|
||||
return Edit { anchor: caret, caret };
|
||||
}
|
||||
// Line starts (char indices) of every line the selection touches. A selection that ends at
|
||||
// the start of the next line (`"a\n"` in `"a\nb"`) does not touch that line.
|
||||
let mut starts = vec![line_start(text, a)];
|
||||
for (i, c) in text.chars().enumerate().skip(a).take(b - a) {
|
||||
if c == '\n' && i + 1 < b {
|
||||
starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
let (mut na, mut nb) = (a, b);
|
||||
// Work from the last line back so earlier indices stay valid.
|
||||
for &s in starts.iter().rev() {
|
||||
let bs = byte_of(text, s);
|
||||
let n = if dedent {
|
||||
let n = text.get(bs..).map(|rest| rest.chars().take(INDENT.len()).take_while(|c| *c == ' ').count()).unwrap_or(0);
|
||||
text.replace_range(bs..bs + n, "");
|
||||
n
|
||||
} else {
|
||||
text.insert_str(bs, INDENT);
|
||||
0
|
||||
};
|
||||
let shift = |p: usize| {
|
||||
if dedent {
|
||||
if p > s { p - n.min(p - s) } else { p }
|
||||
} else if p >= s {
|
||||
p + INDENT.len()
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
na = shift(na);
|
||||
nb = shift(nb);
|
||||
}
|
||||
if sel.1 >= sel.0 { Edit { anchor: na, caret: nb } } else { Edit { anchor: nb, caret: na } }
|
||||
}
|
||||
|
||||
/// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the
|
||||
/// caret.
|
||||
pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = font();
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
||||
let max_lines = max_lines.clamp(1, 200);
|
||||
// The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in
|
||||
// input order with the typing around it, before the text field sees the rest; what had to
|
||||
// wait for the field in the previous pass comes first.
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let held = take_held(ui, id, focused);
|
||||
let mut edited = None;
|
||||
if focused {
|
||||
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let count = text.chars().count();
|
||||
let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count));
|
||||
let applied = ui.input_mut(|i| {
|
||||
if !held.is_empty() {
|
||||
let mut events = held;
|
||||
events.append(&mut i.events);
|
||||
i.events = events;
|
||||
}
|
||||
apply_in_order(&mut i.events, text, &mut sel, lang)
|
||||
});
|
||||
if applied.edited {
|
||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1))));
|
||||
state.store(ui.ctx(), id);
|
||||
edited = Some(Edit { anchor: sel.0, caret: sel.1 });
|
||||
}
|
||||
if !applied.held.is_empty() {
|
||||
hold(ui, id, applied.held);
|
||||
}
|
||||
}
|
||||
let syntax = t.syntax;
|
||||
let plain = t.text;
|
||||
let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
|
||||
ui.fonts_mut(|f| f.layout_job(job))
|
||||
};
|
||||
let frame_h = row_h * max_lines as f32 + 8.0;
|
||||
let mut response = None;
|
||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let out = TextEdit::multiline(text)
|
||||
.id(id)
|
||||
.font(font.clone())
|
||||
.code_editor()
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(1)
|
||||
.lock_focus(true)
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
if let Some(e) = edited {
|
||||
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(caret, None);
|
||||
}
|
||||
let mut r = out.response.response;
|
||||
if edited.is_some() {
|
||||
r.mark_changed();
|
||||
}
|
||||
response = Some(r);
|
||||
});
|
||||
});
|
||||
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::Modifiers;
|
||||
|
||||
fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> {
|
||||
highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_tokens() {
|
||||
let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python);
|
||||
assert_eq!(
|
||||
s,
|
||||
vec![
|
||||
("def", Token::Keyword),
|
||||
("return", Token::Keyword),
|
||||
("len", Token::Builtin),
|
||||
("1.5", Token::Number),
|
||||
("# done", Token::Comment),
|
||||
("xl", Token::Builtin),
|
||||
("\"A1:B2\"", Token::String),
|
||||
]
|
||||
);
|
||||
assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]);
|
||||
assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]);
|
||||
assert_eq!(spans("hello", Lang::Plain), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_tokens() {
|
||||
let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex);
|
||||
assert_eq!(
|
||||
s,
|
||||
vec![
|
||||
("\\frac", Token::Keyword),
|
||||
("^", Token::Builtin),
|
||||
("2", Token::Number),
|
||||
("3", Token::Number),
|
||||
("% half", Token::Comment),
|
||||
("\\{", Token::Keyword),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() {
|
||||
let inputs = [
|
||||
"",
|
||||
"'",
|
||||
"\"",
|
||||
"'''",
|
||||
"\"\"\"x",
|
||||
"#",
|
||||
"\\",
|
||||
"%",
|
||||
"1",
|
||||
".",
|
||||
"é'ü\\",
|
||||
"θ = 'π' # ∑",
|
||||
"\u{1F600}'\u{1F600}",
|
||||
"'\\",
|
||||
"\\\u{1F600}",
|
||||
"\u{200B}\n\t'''\n",
|
||||
"0x_é",
|
||||
"a.b.c",
|
||||
"____",
|
||||
];
|
||||
for text in inputs {
|
||||
for lang in [Lang::Python, Lang::Latex, Lang::Plain] {
|
||||
let mut last = 0;
|
||||
for (r, _) in highlight(text, lang) {
|
||||
assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}");
|
||||
assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}");
|
||||
last = r.end;
|
||||
}
|
||||
let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY);
|
||||
assert_eq!(job.text, text, "every character is laid out once");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_keeps_indentation_and_indents_after_a_colon() {
|
||||
let mut s = " x = 1".to_string();
|
||||
let e = newline(&mut s, (9, 9), Lang::Python);
|
||||
assert_eq!(s, " x = 1\n ");
|
||||
assert_eq!(e.caret, 14);
|
||||
let mut s = "if x:".to_string();
|
||||
let e = newline(&mut s, (5, 5), Lang::Python);
|
||||
assert_eq!(s, "if x:\n ");
|
||||
assert_eq!(e.caret, 10);
|
||||
// Plain text keeps the indentation but adds none after a colon.
|
||||
let mut s = " a:".to_string();
|
||||
newline(&mut s, (4, 4), Lang::Plain);
|
||||
assert_eq!(s, " a:\n ");
|
||||
// A selection is replaced; out-of-range carets are clamped.
|
||||
let mut s = "abcdef".to_string();
|
||||
let e = newline(&mut s, (4, 2), Lang::Plain);
|
||||
assert_eq!((s.as_str(), e.caret), ("ab\nef", 3));
|
||||
let mut s = "é".to_string();
|
||||
let e = newline(&mut s, (99, 99), Lang::Plain);
|
||||
assert_eq!((s.as_str(), e.caret), ("é\n", 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_in_the_editor_dedents_rather_than_indents() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, text: &mut String| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ui.ctx().set_fonts(fonts);
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, text, Lang::Python, 5);
|
||||
},
|
||||
" x".to_string(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
h.input_mut().events.push(tab(Modifiers::SHIFT));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), " x");
|
||||
// A plain Tab still inserts spaces at the caret (the end).
|
||||
h.input_mut().events.push(tab(Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), " x ");
|
||||
}
|
||||
|
||||
/// Gives the context the editor's monospace family (the test has no craft fonts).
|
||||
fn install_mono(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, text: &mut String| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, text, Lang::Python, 5);
|
||||
},
|
||||
text.to_string(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn key(key: Key, modifiers: Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_input_applies_in_order_and_keeps_every_key() {
|
||||
// Events gather between repaints (typing, key repeat). The text typed before an Enter
|
||||
// comes first, and two Enters make two lines.
|
||||
let mut h = editor_harness("");
|
||||
h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "if x:\n \n ");
|
||||
// Backspace and Tab in the same frame as the typing around them.
|
||||
h.input_mut().events.extend([
|
||||
egui::Event::Text("y = 1".into()),
|
||||
key(Key::Backspace, Modifiers::NONE),
|
||||
egui::Event::Text("2".into()),
|
||||
key(Key::Tab, Modifiers::NONE),
|
||||
]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "if x:\n \n y = 2 ");
|
||||
// An event the field acts on (an arrow) keeps the order too: the text typed before it
|
||||
// is applied first, the field moves the caret, and the Enter after it waits for the
|
||||
// next pass, where the editor applies it at the moved caret, with the indentation.
|
||||
h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow");
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "if x:\n \n y = 2 \n a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_ending_at_a_line_start_leaves_that_line_alone() {
|
||||
// Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected.
|
||||
let mut s = "a\nb".to_string();
|
||||
indent(&mut s, (0, 2), false);
|
||||
assert_eq!(s, " a\nb");
|
||||
let mut s = " a\n b".to_string();
|
||||
indent(&mut s, (0, 6), true);
|
||||
assert_eq!(s, "a\n b");
|
||||
// One character into the second line, both lines are touched.
|
||||
let mut s = "a\nb".to_string();
|
||||
indent(&mut s, (0, 3), false);
|
||||
assert_eq!(s, " a\n b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_text_and_deletions_replace_the_selection() {
|
||||
let mut s = "abcd".to_string();
|
||||
assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 });
|
||||
assert_eq!(s, "aXd");
|
||||
assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 });
|
||||
assert_eq!(s, "ad");
|
||||
assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 });
|
||||
assert_eq!(s, "a");
|
||||
assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 });
|
||||
assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 });
|
||||
assert_eq!(s, "a");
|
||||
let mut s = "éa".to_string();
|
||||
delete(&mut s, (2, 0), true);
|
||||
assert_eq!(s, "");
|
||||
// The queue keeps what the editor does not handle, and everything after it.
|
||||
let mut events = vec![
|
||||
egui::Event::Text("x".into()),
|
||||
egui::Event::Text("\n".into()),
|
||||
key(Key::ArrowLeft, Modifiers::NONE),
|
||||
key(Key::Enter, Modifiers::NONE),
|
||||
];
|
||||
let (mut s, mut sel) = (String::new(), (0, 0));
|
||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
||||
assert!(out.edited);
|
||||
assert_eq!((s.as_str(), sel), ("x", (1, 1)));
|
||||
assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}");
|
||||
assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass");
|
||||
let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }];
|
||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
||||
assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply");
|
||||
assert_eq!(events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_then_tab_in_one_pass_indents_every_line() {
|
||||
// Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the
|
||||
// selection and indents the selected lines, rather than reaching the field, which would
|
||||
// replace the selection with a tab character.
|
||||
let mut h = editor_harness("a\nb");
|
||||
h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "a\nb", "the Tab waits for the selection");
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), " a\n b");
|
||||
h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state(), "a\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() {
|
||||
// egui-winit sends a Key event before each Text event. Those presses are nobody's to
|
||||
// read, so they do not hand the Enter to the field, which knows no indentation.
|
||||
let mut h = editor_harness("");
|
||||
let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())];
|
||||
let mut events = Vec::new();
|
||||
events.extend(typed(Key::I, "i", Modifiers::NONE));
|
||||
events.extend(typed(Key::F, "f", Modifiers::NONE));
|
||||
events.extend(typed(Key::Space, " ", Modifiers::NONE));
|
||||
events.extend(typed(Key::X, "x", Modifiers::NONE));
|
||||
events.extend(typed(Key::Colon, ":", Modifiers::SHIFT));
|
||||
events.push(key(Key::Enter, Modifiers::NONE));
|
||||
h.input_mut().events.extend(events);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "if x:\n ");
|
||||
// A character typed with Option (macOS) comes as a key under that modifier and its text;
|
||||
// the field reads neither the key nor Option, so the Enter after it is still the editor's.
|
||||
h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "if x:\n \u{e9}\n ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_enter_under_modifiers_never_reach_the_field() {
|
||||
// The field would make a tab character of Ctrl+Tab (in place of the selection) and a
|
||||
// newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut.
|
||||
let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)];
|
||||
let (mut s, mut sel) = ("if x:".to_string(), (5, 5));
|
||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
||||
assert_eq!(s, "if x:\n ");
|
||||
assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]);
|
||||
assert!(out.held.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() {
|
||||
let mut events = vec![
|
||||
key(Key::X, Modifiers::NONE),
|
||||
egui::Event::Text("x".into()),
|
||||
key(Key::A, Modifiers::COMMAND),
|
||||
key(Key::Tab, Modifiers::NONE),
|
||||
egui::Event::PointerMoved(egui::Pos2::ZERO),
|
||||
key(Key::ArrowLeft, Modifiers::NONE),
|
||||
egui::Event::Text("y".into()),
|
||||
];
|
||||
let (mut s, mut sel) = (String::new(), (0, 0));
|
||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
||||
assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied");
|
||||
assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]);
|
||||
assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_back_events_land_in_the_next_pass_only() {
|
||||
let mut h = editor_harness("");
|
||||
let id = egui::Id::new("code");
|
||||
let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] };
|
||||
// What the pass just completed held back comes in with the next one.
|
||||
let last = h.ctx.cumulative_pass_nr() - 1;
|
||||
h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a")));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "a");
|
||||
// Anything older is dropped rather than typed out of context, and cleared.
|
||||
h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b")));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state(), "a");
|
||||
assert!(h.ctx.data(|d| d.get_temp::<Held>(held_id(id))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() {
|
||||
// Typed into the editor, they must not land in whatever has the focus next.
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, s: &mut (String, String, bool)| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let (code, other) = (egui::Id::new("code"), egui::Id::new("other"));
|
||||
ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other }));
|
||||
code_editor(ui, code, &mut s.0, Lang::Python, 5);
|
||||
ui.add(TextEdit::singleline(&mut s.1).id(other));
|
||||
},
|
||||
(String::new(), String::new(), true),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().0, "", "the x waits for the arrow");
|
||||
h.state_mut().2 = false;
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() {
|
||||
// When a widget asks egui to discard and repeat the pass, the repeat pass gets the
|
||||
// held-back keys, with the selection the field made in the first pass.
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, s: &mut (String, bool)| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, &mut s.0, Lang::Python, 5);
|
||||
if std::mem::take(&mut s.1) {
|
||||
ui.ctx().request_discard("the test repeats this pass");
|
||||
}
|
||||
},
|
||||
("a\nb".to_string(), false),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.state_mut().1 = true;
|
||||
h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().0, " a\n b");
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
for (id, label, params) in [
|
||||
("ui.ribbonTab", "Ribbon Tab", ""),
|
||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
|
||||
("view.darkMode", "Dark Mode", ""),
|
||||
("view.theme", "Display Theme", ""),
|
||||
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
|
||||
|
||||
+252
-18
@@ -7,6 +7,7 @@
|
||||
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||
use serde_json::{Map, Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
@@ -44,6 +45,28 @@ pub struct Dialog {
|
||||
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
|
||||
back: Option<Box<Dialog>>,
|
||||
focus_initialized: bool,
|
||||
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
|
||||
pick: Option<RefPick>,
|
||||
}
|
||||
|
||||
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
|
||||
/// reference at the caret instead of selecting it, and a drag grows it into a range.
|
||||
#[derive(Clone, Debug)]
|
||||
struct RefPick {
|
||||
key: &'static str,
|
||||
/// The sheet the dialog was opened on, by name: its index changes when sheets are inserted or
|
||||
/// moved while the dialog is open. References to another sheet carry that sheet's name.
|
||||
sheet: String,
|
||||
/// Caret (char index), mirrored from the text box while it has the keyboard.
|
||||
caret: usize,
|
||||
/// The reference last inserted (byte span), replaced by the next click or the drag.
|
||||
point: Option<(usize, usize)>,
|
||||
anchor: CellRef,
|
||||
/// Give the box the keyboard back, caret after the reference, on the next frame.
|
||||
refocus: bool,
|
||||
/// The selection the rule applies to, fixed when the dialog opened as Excel's "Applies to":
|
||||
/// pointing on another sheet must not move the rule there.
|
||||
applies_to: String,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
@@ -77,6 +100,46 @@ impl Dialog {
|
||||
self.values.insert(key.into(), value);
|
||||
}
|
||||
|
||||
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
|
||||
pub fn picks_references(&self) -> bool {
|
||||
self.pick.is_some()
|
||||
}
|
||||
|
||||
/// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the
|
||||
/// formula box's caret, replacing the reference the press inserted, as in the formula bar's
|
||||
/// point mode. An empty box starts with `=`. After an operand there is no place for a
|
||||
/// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the
|
||||
/// dialog's own.
|
||||
pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) {
|
||||
let Some(pick) = self.pick.as_mut() else { return };
|
||||
let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let mut ed = crate::editor::EditState::new(0, cell, text, true, false);
|
||||
ed.caret = pick.caret.min(ed.text.chars().count());
|
||||
ed.point = pick.point;
|
||||
if ed.text.trim().is_empty() {
|
||||
ed.text = "=".into();
|
||||
ed.caret = 1;
|
||||
ed.point = None;
|
||||
}
|
||||
if !ed.can_point() {
|
||||
return;
|
||||
}
|
||||
if !extend {
|
||||
pick.anchor = cell;
|
||||
}
|
||||
let range = abs(&RangeRef::new(pick.anchor, cell));
|
||||
let reference = if active_sheet.eq_ignore_ascii_case(&pick.sheet) {
|
||||
range
|
||||
} else {
|
||||
format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet))
|
||||
};
|
||||
ed.insert_ref(&reference);
|
||||
pick.caret = ed.caret;
|
||||
pick.point = ed.point;
|
||||
pick.refocus = true;
|
||||
self.values.insert(pick.key.into(), json!(ed.text));
|
||||
}
|
||||
|
||||
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
|
||||
Dialog {
|
||||
name: name.into(),
|
||||
@@ -94,6 +157,7 @@ impl Dialog {
|
||||
picture_target: None,
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +178,7 @@ impl Dialog {
|
||||
picture_target: None,
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +343,7 @@ impl Dialog {
|
||||
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
||||
// on the web) and goes back to `file.open` with the password.
|
||||
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
||||
let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
|
||||
let file = std::path::Path::new(&source).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("This workbook").to_string();
|
||||
// An empty password from the start, so OK on the untouched field says "incorrect"
|
||||
// rather than asking for the password again.
|
||||
let mut values = params.clone();
|
||||
@@ -577,6 +642,20 @@ impl Dialog {
|
||||
],
|
||||
json!({"groupBy": "A", "function": "sum"}),
|
||||
),
|
||||
"outlineSettings" => {
|
||||
let (below, right) =
|
||||
app.session.active().and_then(|d| d.wb.active().map(|s| (s.summary_below, s.summary_right))).unwrap_or((true, true));
|
||||
Dialog::form(
|
||||
"outlineSettings",
|
||||
"Settings",
|
||||
"data.outlineSettings",
|
||||
vec![
|
||||
Check { key: "summaryBelow", label: "Summary rows below detail" },
|
||||
Check { key: "summaryRight", label: "Summary columns to right of detail" },
|
||||
],
|
||||
json!({"summaryBelow": below, "summaryRight": right}),
|
||||
)
|
||||
}
|
||||
"zoom" => {
|
||||
// The current zoom is pre-selected (Custom when it matches no preset). Fit
|
||||
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
|
||||
@@ -690,6 +769,19 @@ impl Dialog {
|
||||
fields,
|
||||
json!({"preset": "lightRedFillDarkRedText", "period": "today"}),
|
||||
);
|
||||
if ty == "expression" {
|
||||
let (sheet, applies_to) = app
|
||||
.session
|
||||
.active()
|
||||
.map(|doc| {
|
||||
(
|
||||
doc.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
|
||||
doc.selection.ranges.iter().map(RangeRef::a1).collect::<Vec<_>>().join(","),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to });
|
||||
}
|
||||
d.values.insert("type".into(), json!(ty));
|
||||
d.values.insert("operator".into(), p("operator"));
|
||||
d
|
||||
@@ -708,7 +800,12 @@ impl Dialog {
|
||||
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
|
||||
d
|
||||
}
|
||||
"goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})),
|
||||
"goTo" => {
|
||||
let mut dialog = Dialog::custom("goTo", "Go To", json!({"reference": ""}));
|
||||
// Listed once, when the dialog opens.
|
||||
dialog.result = Some(json!(go_to_entries(app)));
|
||||
dialog
|
||||
}
|
||||
"pivotCalculatedField" => Dialog::form(
|
||||
"pivotCalculatedField",
|
||||
"Insert Calculated Field",
|
||||
@@ -746,6 +843,19 @@ impl Dialog {
|
||||
Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]}))
|
||||
}
|
||||
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
|
||||
"pasteName" => {
|
||||
// F3 opens it only when the workbook has names to paste, as in Excel.
|
||||
let names = pasteable_names(app);
|
||||
if p("shortcut") == json!(true) && names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// OK runs `formulas.pasteName` with the selected `name`, so `ui.dialog.confirm` does too.
|
||||
let mut dialog = Dialog::custom("pasteName", "Paste Name", json!({"name": names.first()}));
|
||||
dialog.command = Some("formulas.pasteName");
|
||||
// Listed once, when the dialog opens.
|
||||
dialog.result = Some(json!(names));
|
||||
dialog
|
||||
}
|
||||
"manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
|
||||
"commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
|
||||
"agents" => Dialog::custom("agents", "Agent Control", json!({})),
|
||||
@@ -1115,6 +1225,10 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
|
||||
rule.insert(k.clone(), val.clone());
|
||||
}
|
||||
v = json!({"rule": Json::Object(rule)});
|
||||
if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) {
|
||||
v["sheet"] = json!(pick.sheet.as_str());
|
||||
v["range"] = json!(pick.applies_to);
|
||||
}
|
||||
}
|
||||
"series" => {
|
||||
if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::<f64>().ok()) {
|
||||
@@ -1182,6 +1296,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"zoom" => zoom(ui, &mut d, &mut confirm, &mut open),
|
||||
"sort" => sort(app, ui, &mut d, &mut confirm),
|
||||
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
||||
"pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"manageRules" => manage_rules(app, ui),
|
||||
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
||||
@@ -1254,7 +1369,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
d.values.remove("base64");
|
||||
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||||
let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
|
||||
d.values.insert("alt".into(), json!(alt));
|
||||
d.values.insert("path".into(), json!(path));
|
||||
}
|
||||
@@ -1510,7 +1625,7 @@ fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
||||
*confirm = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent));
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent));
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
@@ -1593,10 +1708,8 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let ok = ui.add(
|
||||
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||
);
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent));
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
@@ -1634,6 +1747,25 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
|
||||
d.focus_first_field = false;
|
||||
}
|
||||
|
||||
/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a
|
||||
/// reference in it (the click took the focus), its caret after the reference.
|
||||
fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {
|
||||
if typed {
|
||||
pick.point = None;
|
||||
}
|
||||
if pick.refocus {
|
||||
let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret))));
|
||||
state.store(&r.ctx, r.id);
|
||||
r.request_focus();
|
||||
pick.refocus = false;
|
||||
} else if r.has_focus()
|
||||
&& let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range())
|
||||
{
|
||||
pick.caret = range.primary.index.into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a dialog's `fields`, editing its values.
|
||||
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Field labels, choices and notes are English source strings, translated as they are drawn.
|
||||
@@ -1659,6 +1791,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
if r.changed() {
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) {
|
||||
track_pick(pick, &r, r.changed());
|
||||
}
|
||||
});
|
||||
}
|
||||
Field::Password { key, label } => {
|
||||
@@ -2215,13 +2350,36 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to
|
||||
/// before, newest first.
|
||||
fn go_to_entries(app: &SheetApp) -> Vec<String> {
|
||||
app.session
|
||||
.active()
|
||||
.map(|doc| {
|
||||
let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet);
|
||||
v.extend(doc.go_to_recent.iter().cloned());
|
||||
v
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||||
for n in names {
|
||||
if ui.selectable_label(false, &n).clicked() {
|
||||
d.values.insert("reference".into(), json!(n));
|
||||
let entries: Vec<String> =
|
||||
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
|
||||
let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
// A workbook can hold thousands of names: only the rows in view are laid out.
|
||||
let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y;
|
||||
egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| {
|
||||
for n in entries.get(rows).unwrap_or_default() {
|
||||
let resp = ui.selectable_label(current == *n, n);
|
||||
if resp.clicked() || resp.double_clicked() {
|
||||
d.values.insert("reference".into(), json!(n));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
*confirm = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Reference:");
|
||||
@@ -2235,7 +2393,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
|
||||
let mut open = true;
|
||||
ok_cancel(ui, confirm, &mut open);
|
||||
if *confirm {
|
||||
app.run_or_alert("edit.goTo", json!({"reference": r}));
|
||||
app.run_or_alert("edit.goTo", json!({"reference": r, "record": true}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
if !open {
|
||||
@@ -2392,8 +2550,83 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu
|
||||
});
|
||||
}
|
||||
|
||||
/// The names Paste Names offers in the active workbook.
|
||||
fn pasteable_names(app: &SheetApp) -> Vec<String> {
|
||||
app.session
|
||||
.active()
|
||||
.map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it
|
||||
/// starts one in the active cell with `=` and the name.
|
||||
pub(crate) fn insert_name(app: &mut SheetApp, name: &str) {
|
||||
match app.editor.as_mut() {
|
||||
Some(ed) => ed.insert_at_caret(name),
|
||||
None => app.begin_edit(Some(format!("={name}")), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the
|
||||
/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it
|
||||
/// refers to from the active cell.
|
||||
fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let names: Vec<String> =
|
||||
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
|
||||
// The `name` value is the selection, so `ui.dialog.set` can choose one too.
|
||||
if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) {
|
||||
d.list_index = i;
|
||||
}
|
||||
let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp)));
|
||||
let last = names.len().saturating_sub(1);
|
||||
if down {
|
||||
d.list_index = (d.list_index + 1).min(last);
|
||||
}
|
||||
if up {
|
||||
d.list_index = d.list_index.saturating_sub(1);
|
||||
}
|
||||
d.list_index = d.list_index.min(last);
|
||||
ui.label(app.ui.language.tr("Paste name"));
|
||||
if names.is_empty() {
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics());
|
||||
}
|
||||
// A workbook can hold thousands of names: only the rows in view are laid out.
|
||||
let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y;
|
||||
let step = row_height + ui.spacing().item_spacing.y;
|
||||
egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| {
|
||||
let top = ui.cursor().top() - rows.start as f32 * step;
|
||||
for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) {
|
||||
let r = ui.selectable_label(i == d.list_index, n);
|
||||
if r.clicked() {
|
||||
d.list_index = i;
|
||||
}
|
||||
if r.double_clicked() {
|
||||
*confirm = true;
|
||||
}
|
||||
}
|
||||
// The arrow keys can move the selection onto a row that isn't laid out.
|
||||
if down || up {
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height));
|
||||
if !ui.clip_rect().contains_rect(rect) {
|
||||
ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min }));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// Paste List is disabled while a cell is being edited.
|
||||
if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() {
|
||||
app.run_or_alert("formulas.pasteName", json!({"list": true}));
|
||||
*open = false;
|
||||
return;
|
||||
}
|
||||
if let Some(n) = names.get(d.list_index) {
|
||||
d.values.insert("name".into(), json!(n));
|
||||
}
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`.
|
||||
const ALL_CHARTS: [(&str, &str, &str); 20] = [
|
||||
const ALL_CHARTS: [(&str, &str, &str); 21] = [
|
||||
("column", "", "Clustered Column"),
|
||||
("column", "stacked", "Stacked Column"),
|
||||
("column", "stacked100", "100% Stacked Column"),
|
||||
@@ -2407,6 +2640,7 @@ const ALL_CHARTS: [(&str, &str, &str); 20] = [
|
||||
("doughnut", "", "Doughnut"),
|
||||
("area", "", "Area"),
|
||||
("area", "stacked", "Stacked Area"),
|
||||
("area", "stacked100", "100% Stacked Area"),
|
||||
("scatter", "", "Scatter"),
|
||||
("scatter", "lines", "Scatter with Lines"),
|
||||
("radar", "", "Radar"),
|
||||
@@ -2962,8 +3196,8 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let solve =
|
||||
ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent));
|
||||
if solve.clicked() && !text(d, "objective").trim().is_empty() {
|
||||
*confirm = true;
|
||||
}
|
||||
@@ -3038,7 +3272,7 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo
|
||||
let word = it["word"].as_str().unwrap_or("").to_string();
|
||||
let cell = it["cell"].as_str().unwrap_or("").to_string();
|
||||
ui.label(format!("Not in Dictionary ({cell}):"));
|
||||
ui.label(egui::RichText::new(&word).strong().color(Color32::from_rgb(0xC4, 0x2B, 0x1C)));
|
||||
ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger));
|
||||
let sugg: Vec<String> =
|
||||
it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
ui.label("Suggestions:");
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
//! In-cell / formula-bar editing state: point mode, reference colouring, function
|
||||
//! autocomplete and argument hints.
|
||||
|
||||
use egui::Color32;
|
||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||
|
||||
/// Colours for references in a formula being edited (our own palette).
|
||||
pub const REF_COLORS: [Color32; 8] = [
|
||||
Color32::from_rgb(0x2E, 0x6F, 0xD8),
|
||||
Color32::from_rgb(0xD0, 0x3C, 0x3C),
|
||||
Color32::from_rgb(0x7A, 0x4F, 0xC9),
|
||||
Color32::from_rgb(0x2B, 0x9A, 0x4A),
|
||||
Color32::from_rgb(0xC2, 0x5E, 0x1E),
|
||||
Color32::from_rgb(0x1D, 0x92, 0xA6),
|
||||
Color32::from_rgb(0xB8, 0x3B, 0x8E),
|
||||
Color32::from_rgb(0x8A, 0x7A, 0x1F),
|
||||
];
|
||||
/// How many distinct reference colours a formula cycles through (`Tokens::ref_colors`).
|
||||
pub const REF_COLOR_COUNT: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EditState {
|
||||
@@ -28,6 +18,9 @@ pub struct EditState {
|
||||
pub from_formula_bar: bool,
|
||||
/// Caret position (char index), mirrored from the text widget.
|
||||
pub caret: usize,
|
||||
/// The other end of the selected text (char index), mirrored like `caret`; equal to `caret`
|
||||
/// when nothing is selected.
|
||||
pub anchor: usize,
|
||||
/// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it.
|
||||
pub point: Option<(usize, usize)>,
|
||||
/// Where point mode's keyboard cursor is.
|
||||
@@ -39,6 +32,9 @@ pub struct EditState {
|
||||
pub request_focus: bool,
|
||||
/// The text changed programmatically: push `caret` into the widget on the next frame.
|
||||
pub sync_caret: bool,
|
||||
/// A dialog opened over the edit has the keyboard: when the editor takes it back without a
|
||||
/// change to the caret, the selected text (`anchor`..`caret`) stays selected.
|
||||
pub keep_selection: bool,
|
||||
/// Column AutoComplete: the full entry the typed text completes to.
|
||||
pub completion: Option<String>,
|
||||
/// Move the caret to the end on the next frame.
|
||||
@@ -56,6 +52,7 @@ impl EditState {
|
||||
enter_mode,
|
||||
from_formula_bar,
|
||||
caret,
|
||||
anchor: caret,
|
||||
point: None,
|
||||
point_cell: None,
|
||||
point_anchor: None,
|
||||
@@ -63,6 +60,7 @@ impl EditState {
|
||||
ac_index: 0,
|
||||
request_focus: true,
|
||||
sync_caret: true,
|
||||
keep_selection: false,
|
||||
caret_to_end: true,
|
||||
completion: None,
|
||||
}
|
||||
@@ -112,6 +110,27 @@ impl EditState {
|
||||
self.sync_caret = true;
|
||||
}
|
||||
|
||||
/// Inserts `t` at the caret as typed text (Paste Names), in place of the selected text: the
|
||||
/// caret ends after it, and point mode starts afresh.
|
||||
pub fn insert_at_caret(&mut self, t: &str) {
|
||||
// When code moved the caret this frame (`sync_caret`), the widget has not yet stored it, so `anchor` is out of
|
||||
// date and the name goes in at the caret.
|
||||
let (from, to) = if self.sync_caret { (self.caret, self.caret) } else { (self.anchor.min(self.caret), self.anchor.max(self.caret)) };
|
||||
let byte = |c: usize| self.text.char_indices().nth(c).map(|(i, _)| i).unwrap_or(self.text.len());
|
||||
let (Some(a), Some(b)) = (self.text.get(..byte(from)), self.text.get(byte(to)..)) else { return };
|
||||
self.text = format!("{a}{t}{b}");
|
||||
self.point = None;
|
||||
self.caret = from + t.chars().count();
|
||||
self.anchor = self.caret;
|
||||
// A function suggestion shown before the name went in no longer fits the text.
|
||||
self.autocomplete.clear();
|
||||
self.ac_index = 0;
|
||||
self.completion = None;
|
||||
self.caret_to_end = false;
|
||||
self.sync_caret = true;
|
||||
self.request_focus = true;
|
||||
}
|
||||
|
||||
/// Add another argument after the current pointed reference. Keep only the new reference
|
||||
/// replaceable, so a subsequent plain click does not remove earlier arguments or the separator.
|
||||
pub fn append_ref(&mut self, r: &str) {
|
||||
@@ -293,7 +312,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
|
||||
seen.len() - 1
|
||||
}
|
||||
};
|
||||
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
|
||||
out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT));
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
@@ -379,6 +398,21 @@ mod tests {
|
||||
assert!(!e2.can_point());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_name_replaces_the_selected_text() {
|
||||
let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false);
|
||||
e.sync_caret = false;
|
||||
(e.anchor, e.caret) = (8, 1);
|
||||
e.autocomplete = vec!["SUM".into()];
|
||||
e.insert_at_caret("Sales");
|
||||
assert_eq!((e.text.as_str(), e.caret, e.anchor), ("=Sales+1", 6, 6));
|
||||
assert!(e.autocomplete.is_empty(), "the earlier function suggestion is gone");
|
||||
// Nothing selected: the name goes in at the caret.
|
||||
e.sync_caret = false;
|
||||
e.insert_at_caret("Rate");
|
||||
assert_eq!((e.text.as_str(), e.caret), ("=SalesRate+1", 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_and_hints() {
|
||||
let names: Vec<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
|
||||
|
||||
@@ -95,6 +95,13 @@ impl Cache {
|
||||
}
|
||||
None => (None, u32::MAX),
|
||||
};
|
||||
// Eviction only frees entries used before this frame, so a workbook covered in same-frame
|
||||
// pictures can leave the cache unable to make room. Then draw the texture without
|
||||
// retaining it: the handle is dropped at the end of the frame and the GPU memory goes
|
||||
// with it, instead of the budget being outflanked (finding: cache admission).
|
||||
if self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
|
||||
return texture;
|
||||
}
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame });
|
||||
texture
|
||||
@@ -212,13 +219,24 @@ mod tests {
|
||||
}
|
||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||
assert!(cache.entries.len() < data.len());
|
||||
// Many on screen at once, over the budget: every one keeps its texture, frame after frame.
|
||||
// Many on screen at once, over the budget: each still gets a texture this frame, but the
|
||||
// cache cannot grow past its budget whatever the workbook shows. Eight of the 16 MB
|
||||
// textures fit the 128 MB budget, and those stay pinned; the rest re-upload each frame
|
||||
// instead of being retained over it (finding: cache admission).
|
||||
// Two frames pass so the rehearsal's entries are old enough to be evictable.
|
||||
frame(&ctx);
|
||||
frame(&ctx);
|
||||
let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect();
|
||||
for _ in 0..3 {
|
||||
frame(&ctx);
|
||||
for (i, d) in data.iter().enumerate().take(12) {
|
||||
assert_eq!(cache.texture(&ctx, key(1, i as u32, d), d, 2048).map(|t| t.id()), Some(shown[i]));
|
||||
let id = cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id();
|
||||
if i < 8 {
|
||||
assert_eq!(id, shown[i], "a picture that fits keeps its texture");
|
||||
}
|
||||
}
|
||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||
assert!(cache.entries.len() <= 8, "{} entries kept", cache.entries.len());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn style_weight(style: &str) -> f32 {
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("hairline", 100.0),
|
||||
("thin", 100.0),
|
||||
@@ -107,7 +107,7 @@ impl Face {
|
||||
/// Whether a style name reads as italic or oblique.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn style_italic(style: &str) -> bool {
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
||||
s.contains("italic") || s.contains("oblique")
|
||||
}
|
||||
|
||||
@@ -165,8 +165,10 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let is_font =
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
|
||||
let is_font = path
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
|
||||
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
@@ -366,12 +368,12 @@ fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
|
||||
|
||||
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
|
||||
pub fn set_bound(keys: impl IntoIterator<Item = String>) {
|
||||
*bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect();
|
||||
*bound().write().unwrap_or_else(std::sync::PoisonError::into_inner) = keys.into_iter().collect();
|
||||
}
|
||||
|
||||
/// Is this exact role family bound to font data?
|
||||
pub fn is_bound(role: &str) -> bool {
|
||||
bound().read().unwrap_or_else(|e| e.into_inner()).contains(role)
|
||||
bound().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(role)
|
||||
}
|
||||
|
||||
/// Serializes tests that mutate the process-wide bound-family registry.
|
||||
|
||||
@@ -1,65 +1,149 @@
|
||||
//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget.
|
||||
|
||||
use egui::{Color32, FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2};
|
||||
use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::editor::{REF_COLORS, formula_refs};
|
||||
use crate::editor::formula_refs;
|
||||
use crate::icons::{self, Icon};
|
||||
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 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)
|
||||
/// and two pixels for the rounding to the pixel grid.
|
||||
const PAD: f32 = 14.0;
|
||||
|
||||
/// The bar's height for one line of a font whose rows are `row_h` tall: Excel's 30 px, unless
|
||||
/// the rows need more (the Windows UI font's row is 17.28 px). Every line counted must fit in
|
||||
/// the field, whatever the font; `n` lines take `one_line(row_h) + (n - 1) * row_h`.
|
||||
pub fn one_line(row_h: f32) -> f32 {
|
||||
ONE_LINE.max(row_h + PAD)
|
||||
}
|
||||
/// How long the bar takes to grow or shrink to a new line count.
|
||||
const EASE_SECONDS: f32 = 0.12;
|
||||
|
||||
/// Where the formula bar put its parts last frame (for tests).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BarLayout {
|
||||
pub name_box: Rect,
|
||||
/// ✕ ✓ fx.
|
||||
pub buttons: Rect,
|
||||
pub field: Rect,
|
||||
pub height: f32,
|
||||
/// Laid-out lines of the content (wrapped to the field), and how many show without scrolling.
|
||||
pub lines: usize,
|
||||
pub visible_lines: usize,
|
||||
/// The caret while editing in the bar.
|
||||
pub caret: Option<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 }
|
||||
}
|
||||
}
|
||||
|
||||
impl BarLayout {
|
||||
pub fn id() -> egui::Id {
|
||||
egui::Id::new("formula_bar.layout")
|
||||
}
|
||||
|
||||
/// Last frame's layout.
|
||||
pub fn get(ctx: &egui::Context) -> BarLayout {
|
||||
ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the editor widget laid out: its line count and caret.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct EditorInfo {
|
||||
pub lines: usize,
|
||||
pub caret: Option<Rect>,
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let expanded = app.ui.formula_bar_expanded;
|
||||
let h = if expanded { 72.0 } else { 30.0 };
|
||||
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) };
|
||||
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
|
||||
let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS);
|
||||
let mut layout = BarLayout { height: h, ..Default::default() };
|
||||
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| {
|
||||
ui.horizontal(|ui| {
|
||||
// Top-aligned: the Name Box and buttons stay on the first line as the field grows.
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
name_box(app, ui, &t);
|
||||
layout.name_box = name_box(app, ui, &t);
|
||||
ui.add_space(4.0);
|
||||
let editing = app.editor.is_some();
|
||||
let c = if editing { t.text } else { t.text_disabled };
|
||||
if crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 22.0)).clicked() && editing {
|
||||
let cancel = crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 24.0));
|
||||
if cancel.clicked() && editing {
|
||||
app.cancel_edit();
|
||||
}
|
||||
if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 22.0)).clicked() && editing {
|
||||
if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 24.0)).clicked() && editing {
|
||||
app.commit_edit(0, 0, false, false);
|
||||
}
|
||||
if crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 22.0)).clicked() {
|
||||
let fx = crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 24.0));
|
||||
if fx.clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
layout.buttons = cancel.rect.union(fx.rect);
|
||||
ui.add_space(4.0);
|
||||
// The formula field.
|
||||
// The formula field fills the rest, as tall as the bar.
|
||||
let avail = ui.available_width() - 24.0;
|
||||
let field = Rect::from_min_size(ui.cursor().min, vec2(avail, h - 6.0));
|
||||
let field = Rect::from_min_size(ui.cursor().min, vec2(avail, ui.available_height()));
|
||||
ui.painter().rect_filled(field, 4.0, t.input_bg);
|
||||
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0))));
|
||||
let inner = field.shrink2(vec2(6.0, 3.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
|
||||
let id = egui::Id::new("gridcraft.formula_bar");
|
||||
// A spill child shows its anchor's formula muted and read-only.
|
||||
let read_only = active_spill_anchor(app).is_some();
|
||||
if app.editor.is_some() {
|
||||
editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0);
|
||||
let info = if app.editor.is_some() {
|
||||
editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height())
|
||||
} else {
|
||||
// Show the active cell's content; clicking starts editing in the bar.
|
||||
let text = active_input(app);
|
||||
let color = if read_only { t.text_disabled } else { t.text };
|
||||
let resp = child
|
||||
.add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click()));
|
||||
let click = child.interact(field, id.with("click"), Sense::click());
|
||||
if !read_only && (resp.clicked() || click.clicked()) {
|
||||
let galley = child.painter().layout(text, font.clone(), color, inner.width());
|
||||
let lines = galley.rows.len();
|
||||
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
|
||||
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col));
|
||||
egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show(
|
||||
&mut child,
|
||||
|ui| {
|
||||
if ui.add(egui::Label::new(galley).sense(Sense::click())).clicked() && !read_only {
|
||||
app.begin_edit(None, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
if !read_only && ui.interact(field, id.with("click"), Sense::click()).clicked() {
|
||||
app.begin_edit(None, true);
|
||||
}
|
||||
}
|
||||
ui.allocate_space(vec2(avail, h - 6.0));
|
||||
let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron };
|
||||
if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() {
|
||||
EditorInfo { lines, caret: None }
|
||||
};
|
||||
layout.field = field;
|
||||
layout.lines = info.lines.max(1);
|
||||
layout.visible_lines = ((inner.height() / row_h).floor() as usize).max(1);
|
||||
layout.caret = info.caret;
|
||||
ui.allocate_space(vec2(avail, field.height()));
|
||||
let chevron = if app.ui.formula_bar_expanded { Icon::ChevronUp } else { Icon::Chevron };
|
||||
if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 24.0)).clicked() {
|
||||
app.ui.formula_bar_expanded = !app.ui.formula_bar_expanded;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout));
|
||||
}
|
||||
|
||||
/// Resolve ownership on every frame; never cache a spill range in presentation state.
|
||||
@@ -101,7 +185,7 @@ fn active_input(app: &SheetApp) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
|
||||
let id = egui::Id::new("gridcraft.name_box");
|
||||
let shown = app.name_box_text();
|
||||
let mut text = app.name_box.clone().unwrap_or(shown.clone());
|
||||
@@ -158,6 +242,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
});
|
||||
rect
|
||||
}
|
||||
|
||||
/// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character.
|
||||
@@ -172,9 +257,33 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool {
|
||||
text.chars().count() < chars_before
|
||||
}
|
||||
|
||||
/// The text editor used in the cell and in the formula bar (they share `app.editor`).
|
||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) {
|
||||
let Some(mut ed) = app.editor.take() else { return };
|
||||
/// The editor's text field, the same in the cell and in the formula bar. Enter is the editor's
|
||||
/// own key (it commits, and Alt+Enter breaks the line), so the field itself does nothing with it:
|
||||
/// a message box over the editor takes Enter to close, and the field must not add a line then.
|
||||
fn show_edit<'t>(
|
||||
edit: TextEdit<'t>,
|
||||
ui: &mut egui::Ui,
|
||||
id: egui::Id,
|
||||
font: FontId,
|
||||
width: f32,
|
||||
layouter: &'t mut dyn FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc<egui::Galley>,
|
||||
) -> egui::text_edit::TextEditOutput {
|
||||
edit.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.font(font)
|
||||
.desired_width(width)
|
||||
.desired_rows(1)
|
||||
.layouter(layouter)
|
||||
.lock_focus(true)
|
||||
.return_key(None)
|
||||
.event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
.show(ui)
|
||||
}
|
||||
|
||||
/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows
|
||||
/// `max_height` of text and scrolls beyond that, keeping the caret in view.
|
||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar"));
|
||||
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
@@ -184,7 +293,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
let mut cancel = false;
|
||||
// A message box over the editor (a data validation alert, a formula error) gets Enter and Esc;
|
||||
// the editor used to take them, so the first Esc cancelled the edit behind the message.
|
||||
if has_focus && mine && app.message.is_none() {
|
||||
// So does a dialog opened from the editor (Paste Name): its OK takes Enter.
|
||||
if has_focus && mine && app.message.is_none() && app.dialog.is_none() {
|
||||
let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| {
|
||||
let m = i.modifiers;
|
||||
let alt_enter =
|
||||
@@ -274,6 +384,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) {
|
||||
cycle_anchor(&mut ed);
|
||||
}
|
||||
// F3 opens Paste Name when the workbook has names; OK puts the name at the caret.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) {
|
||||
app.open_dialog("pasteName", json!({"shortcut": true}));
|
||||
}
|
||||
// Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere,
|
||||
// as in Excel, so it never falls through to the text's select-all.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) {
|
||||
@@ -290,11 +404,16 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
}
|
||||
// Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar while editing too.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) {
|
||||
app.run_or_alert("view.expandFormulaBar", json!({}));
|
||||
}
|
||||
}
|
||||
// The text widget.
|
||||
let refs = formula_refs(&ed.text);
|
||||
let text_color = Tokens::get(ui.ctx()).text;
|
||||
let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let on_sheet = id == egui::Id::new("gridcraft.cell_editor");
|
||||
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
|
||||
let font2 = font.clone();
|
||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let s = buf.as_str();
|
||||
@@ -307,7 +426,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
continue;
|
||||
}
|
||||
job.append(s.get(pos..a).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color));
|
||||
job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), REF_COLORS[idx]));
|
||||
job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), t.ref_color(idx, on_sheet)));
|
||||
pos = b;
|
||||
}
|
||||
job.append(s.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color));
|
||||
@@ -315,32 +434,55 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ui.fonts_mut(|f| f.layout_job(job))
|
||||
};
|
||||
let chars_before = ed.text.chars().count();
|
||||
// The in-cell editor is as tall as its lines (a multiline field reserves four rows).
|
||||
let in_cell = id == egui::Id::new("gridcraft.cell_editor");
|
||||
let edit = if multiline || ed.text.contains('\n') {
|
||||
TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 })
|
||||
// A dialog opened over the edit (Paste Name) takes the keyboard until it closes, and the
|
||||
// editor takes it back afterwards.
|
||||
let dialog_open = app.dialog.is_some();
|
||||
if dialog_open && mine {
|
||||
ed.request_focus = true;
|
||||
ed.keep_selection = true;
|
||||
}
|
||||
let synced = mine && !dialog_open && (ed.request_focus || ed.sync_caret);
|
||||
let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) };
|
||||
let edit = edit.interactive(!dialog_open);
|
||||
let caret_index = ed.caret;
|
||||
// The in-cell editor grows with its text and never scrolls, so the field keeps its whole width
|
||||
// for the text (a scroll area would take the bar's width off it and wrap the text narrower
|
||||
// than the cell does). The formula bar shows its lines and scrolls past them.
|
||||
let mut output = if on_sheet {
|
||||
show_edit(edit, ui, id, font, width, &mut layouter)
|
||||
} else {
|
||||
TextEdit::singleline(&mut ed.text)
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(id.with("scroll"))
|
||||
.max_height(max_height.max(1.0))
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
let output = show_edit(edit, ui, id, font, width, &mut layouter);
|
||||
// A caret moved by our own keys (Alt+Enter, F4, point mode) isn't the TextEdit's
|
||||
// doing, so it doesn't scroll to it itself.
|
||||
if synced {
|
||||
let caret = output.galley.pos_from_cursor(egui::text::CCursor::new(caret_index)).translate(output.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(caret.expand(2.0), None);
|
||||
}
|
||||
output
|
||||
})
|
||||
.inner
|
||||
};
|
||||
let mut output = edit
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.font(font)
|
||||
.desired_width(width)
|
||||
.layouter(&mut layouter)
|
||||
.lock_focus(true)
|
||||
.event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
.show(ui);
|
||||
let resp = output.response.clone();
|
||||
let synced = mine && (ed.request_focus || ed.sync_caret);
|
||||
if synced {
|
||||
if ed.request_focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
ed.request_focus = false;
|
||||
// Closing a dialog that changed nothing (Cancel) gives the selection back as it was.
|
||||
let range = if ed.keep_selection && !ed.sync_caret {
|
||||
egui::text::CCursorRange::two(egui::text::CCursor::new(ed.anchor), egui::text::CCursor::new(ed.caret))
|
||||
} else {
|
||||
ed.anchor = ed.caret;
|
||||
egui::text::CCursorRange::one(egui::text::CCursor::new(ed.caret))
|
||||
};
|
||||
ed.sync_caret = false;
|
||||
let cursor = egui::text::CCursor::new(ed.caret);
|
||||
output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(cursor)));
|
||||
ed.keep_selection = false;
|
||||
output.state.cursor.set_char_range(Some(range));
|
||||
output.state.store(ui.ctx(), id);
|
||||
} else if resp.clicked() || resp.gained_focus() {
|
||||
// Clicking into the other editor moves editing there.
|
||||
@@ -348,8 +490,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ed.enter_mode = false;
|
||||
app.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
if !synced && let Some(r) = output.cursor_range {
|
||||
if !synced
|
||||
&& !dialog_open
|
||||
&& let Some(r) = output.cursor_range
|
||||
{
|
||||
ed.caret = r.primary.index.into();
|
||||
ed.anchor = r.secondary.index.into();
|
||||
}
|
||||
if resp.changed() {
|
||||
ed.completion = None;
|
||||
@@ -388,11 +534,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
let g = ui.painter().layout_no_wrap(
|
||||
rest.to_string(),
|
||||
output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(),
|
||||
Color32::WHITE,
|
||||
t.on_accent,
|
||||
);
|
||||
let r = egui::Rect::from_min_size(pos, g.size());
|
||||
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8));
|
||||
ui.painter().galley(pos, g, Color32::WHITE);
|
||||
ui.painter().rect_filled(r, 0.0, t.accent);
|
||||
ui.painter().galley(pos, g, t.on_accent);
|
||||
}
|
||||
// Autocomplete list and argument hint under the editor.
|
||||
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
@@ -456,6 +602,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
});
|
||||
});
|
||||
}
|
||||
let lines = output.galley.rows.len().max(1);
|
||||
let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2()));
|
||||
app.editor = Some(ed);
|
||||
if cancel {
|
||||
app.cancel_edit();
|
||||
@@ -466,6 +614,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
app.commit_edit(dr, dc, array, fill);
|
||||
}
|
||||
}
|
||||
EditorInfo { lines, caret }
|
||||
}
|
||||
|
||||
fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
||||
@@ -686,4 +835,21 @@ mod tests {
|
||||
assert!(entry_shrank(4, "Som"));
|
||||
assert!(!entry_shrank(4, "Same"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_line_fits_a_row_of_any_font() {
|
||||
// macOS and Linux UI fonts: rows of 15 to 16 px keep Excel's 30 px bar.
|
||||
assert_eq!(one_line(15.6), 30.0);
|
||||
assert_eq!(one_line(16.0), 30.0);
|
||||
// The Windows UI font's 17.28 px rows need more: the panel's and the field's margins take
|
||||
// 12 px, and rounding to the pixel grid up to another one, so 30 px held only four rows
|
||||
// of five (a tester's finding). Every counted line must fit in what is left.
|
||||
for row_h in [17.0_f32, 17.28, 18.0, 20.0] {
|
||||
for lines in 1..=MAX_LINES {
|
||||
let bar = one_line(row_h) + (lines - 1) as f32 * row_h;
|
||||
let inner = bar - 13.0;
|
||||
assert!((inner / row_h).floor() as usize >= lines, "{lines} lines of {row_h} px in a {bar} px bar");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
-20
@@ -12,7 +12,7 @@ use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex,
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::editor::{EditState, REF_COLORS};
|
||||
use crate::editor::EditState;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
pub const COL_HEADER_H: f32 = 21.0;
|
||||
@@ -163,6 +163,11 @@ pub struct GridState {
|
||||
/// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot):
|
||||
/// it scans the whole source list, far too slow to repeat every frame (#2113).
|
||||
pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>,
|
||||
/// The cells of a PivotTable report where the cell menu offers Show Details, relative to the
|
||||
/// report's top-left and cached per (doc uid, revision, sheet, pivot): finding them computes
|
||||
/// the whole report. `None` when the report can't be computed or is out of date: every cell
|
||||
/// of it offers Show Details, and running it says why.
|
||||
pub show_details_cells: Option<((u64, u64, usize, usize), Option<std::collections::HashSet<(u32, u32)>>)>,
|
||||
pub header_menu: Option<(Pos2, bool)>,
|
||||
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
|
||||
pub paper: Option<crate::paper_view::PaperCache>,
|
||||
@@ -176,6 +181,11 @@ pub struct GridState {
|
||||
pub geo: Option<Geo>,
|
||||
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
|
||||
pub hscroll: Option<Rect>,
|
||||
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
|
||||
pub tab_split: Option<Rect>,
|
||||
/// The share of the tab bar each workbook's tabs (and New sheet) take after the handle was
|
||||
/// dragged, by document uid. A workbook without one gives its tabs up to 60%.
|
||||
pub tab_shares: std::collections::HashMap<u64, f32>,
|
||||
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
|
||||
pub wheel_zoom: Option<WheelZoom>,
|
||||
/// A one-finger touch drag in progress: it pans the sheet instead of selecting.
|
||||
@@ -252,8 +262,8 @@ impl Geo {
|
||||
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
|
||||
let frozen_w = (ci.start(fc) as f32) * z;
|
||||
let frozen_h = (ri.start(fr) as f32) * z;
|
||||
let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
|
||||
let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
|
||||
let row_outline = Arc::new(crate::outline::Axis::of(sh, true));
|
||||
let col_outline = Arc::new(crate::outline::Axis::of(sh, false));
|
||||
// Show Outline Symbols off: no bands (the outline's hidden lines stay hidden).
|
||||
let symbols = sh.show_outline_symbols;
|
||||
let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 };
|
||||
@@ -1227,7 +1237,7 @@ pub(crate) fn paint_quadrant(
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
if on {
|
||||
crate::icons::paint(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE);
|
||||
crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1558,9 +1568,15 @@ pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec<TableHeadin
|
||||
}
|
||||
|
||||
fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) {
|
||||
p.rect_filled(r, 2.0, Color32::from_gray(250));
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside);
|
||||
crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70));
|
||||
let t = Tokens::get(p.ctx());
|
||||
p.rect_filled(r, 2.0, t.sheet_control_bg);
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
||||
crate::icons::paint_on_sheet(
|
||||
p,
|
||||
r.shrink(1.5),
|
||||
if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron },
|
||||
t.sheet_control_text,
|
||||
);
|
||||
}
|
||||
|
||||
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
||||
@@ -1789,7 +1805,7 @@ pub(crate) fn paint_selection(
|
||||
continue;
|
||||
}
|
||||
let rr = geo.range_rect(sh, r);
|
||||
let col = REF_COLORS[idx];
|
||||
let col = Tokens::get(p.ctx()).ref_color(idx, true);
|
||||
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
|
||||
p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside);
|
||||
for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] {
|
||||
@@ -2184,9 +2200,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
app.selected_chart = None;
|
||||
}
|
||||
app.selected_chart = None;
|
||||
}
|
||||
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
|
||||
// under it; a right press that wobbles into a drag counts too (#2067).
|
||||
@@ -2336,6 +2351,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p)));
|
||||
return;
|
||||
}
|
||||
// A dialog's formula box in point mode (New Formatting Rule) takes the reference; the
|
||||
// selection, which the dialog acts on, stays.
|
||||
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
|
||||
d.pick_reference(c, &sh.name, false);
|
||||
app.grid.drag = Drag::Select;
|
||||
app.grid.drag_select = true;
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Point mode while editing a formula: clicking inserts a reference.
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.can_point()
|
||||
@@ -2658,7 +2686,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while
|
||||
/// pointing a formula reference, which also drags a Select).
|
||||
fn format_painter_apply(app: &mut SheetApp) {
|
||||
if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) {
|
||||
if app.session.format_painter.is_some()
|
||||
&& !app.editor.as_ref().is_some_and(|ed| ed.can_point())
|
||||
&& !app.dialog.as_ref().is_some_and(|d| d.picks_references())
|
||||
{
|
||||
let dest = app.session.active().map(|d| d.selection.current().a1());
|
||||
if let Some(dest) = dest
|
||||
&& let Err(e) = app.run("edit.formatPainter", json!({"apply": dest}))
|
||||
@@ -2711,7 +2742,9 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
|
||||
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
||||
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
|
||||
);
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
|
||||
d.pick_reference(cell, &sh.name, true);
|
||||
} else if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.point.is_some()
|
||||
{
|
||||
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) {
|
||||
@@ -3131,6 +3164,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
let size = st.font.size * 96.0 / 72.0 * geo.z;
|
||||
let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));
|
||||
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
|
||||
// Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar
|
||||
// is the one that stops at five lines).
|
||||
let shown = lines;
|
||||
// Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does.
|
||||
// Otherwise it widens to the text's one-line width.
|
||||
let wrap = st.align.wrap;
|
||||
@@ -3138,7 +3174,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
// The field wraps at the width it is given (the editor's less its 2 px inset a side): its
|
||||
// frame is `Frame::NONE`, so no text margin is taken off.
|
||||
let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0));
|
||||
(rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height()))
|
||||
(rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height()))
|
||||
} else {
|
||||
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
|
||||
// Excel keeps the cell's height for one line and only grows for extra lines.
|
||||
@@ -3166,7 +3202,8 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
|
||||
// theme, on the white paper): pick black or white against the fill, like Excel.
|
||||
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect));
|
||||
}
|
||||
|
||||
/// Test the popup itself, not whether the pointer is over any part of the app.
|
||||
@@ -3335,6 +3372,12 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
if let Some(item) = insert_copied {
|
||||
items.insert(6, item);
|
||||
}
|
||||
if show_details_offered(app)
|
||||
&& let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending")
|
||||
{
|
||||
items.insert(i + 1, ("-", ""));
|
||||
items.insert(i + 2, ("Show Details", "pivot.showDetails"));
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
for (label, id) in items {
|
||||
if label == "-" {
|
||||
@@ -3391,10 +3434,48 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell
|
||||
/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of
|
||||
/// date, where running the command says so. Labels and headers of a current report don't offer it,
|
||||
/// and no cell does while the PivotTable's Enable show details option is off.
|
||||
fn show_details_offered(app: &mut SheetApp) -> bool {
|
||||
let Some(d) = app.session.active() else { return false };
|
||||
let (si, at) = (d.wb.active_sheet, d.selection.active);
|
||||
let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at))))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !pt.enable_show_details {
|
||||
return false;
|
||||
}
|
||||
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
||||
let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
|
||||
let key = (d.uid, d.revision, si, pi);
|
||||
if let Some((k, cells)) = &app.grid.show_details_cells
|
||||
&& *k == key
|
||||
{
|
||||
return cells.as_ref().is_none_or(|c| c.contains(&cell));
|
||||
}
|
||||
let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok();
|
||||
let on = cells.as_ref().is_none_or(|c| c.contains(&cell));
|
||||
app.grid.show_details_cells = Some((key, cells));
|
||||
on
|
||||
}
|
||||
|
||||
/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them.
|
||||
const FILL_MODES: [(&str, &str); 4] =
|
||||
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
|
||||
|
||||
/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts
|
||||
/// with one.
|
||||
const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")];
|
||||
|
||||
/// The fill modes the menus list for a fill from `src` on the active sheet.
|
||||
fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> {
|
||||
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src));
|
||||
FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect()
|
||||
}
|
||||
|
||||
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
|
||||
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
||||
app.grid.fill_tag =
|
||||
@@ -3403,7 +3484,8 @@ fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
||||
|
||||
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
|
||||
/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only
|
||||
/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
|
||||
/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or
|
||||
/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
|
||||
/// The button goes away as soon as anything else changes the workbook or the selection moves.
|
||||
fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) {
|
||||
let Some(tag) = app.grid.fill_tag.clone() else { return };
|
||||
@@ -3446,9 +3528,10 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
Stroke::NONE,
|
||||
));
|
||||
let mut chosen = None;
|
||||
let modes = fill_modes(app, tag.src);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(190.0);
|
||||
for (label, mode) in FILL_MODES {
|
||||
for (label, mode) in modes {
|
||||
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
||||
chosen = Some(mode);
|
||||
ui.close();
|
||||
@@ -3475,10 +3558,11 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((src, target, at)) = app.grid.fill_menu else { return };
|
||||
let mut close = false;
|
||||
let mut items = fill_modes(app, src);
|
||||
items.extend([("-", ""), ("Series…", "ui:series")]);
|
||||
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")];
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
for (label, mode) in items {
|
||||
if label == "-" {
|
||||
@@ -3644,9 +3728,10 @@ pub(crate) fn paint_overlays(
|
||||
if app.editor.is_none()
|
||||
&& let Some(r) = validation_arrow(sh, geo, sel.active)
|
||||
{
|
||||
p.rect_filled(r, 2.0, Color32::from_gray(245));
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside);
|
||||
crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60));
|
||||
let t = Tokens::get(p.ctx());
|
||||
p.rect_filled(r, 2.0, t.sheet_control_bg);
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
||||
crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text);
|
||||
}
|
||||
// Input message of the active cell's validation, hidden while its list is open: it would cover the items.
|
||||
if app.editor.is_none()
|
||||
|
||||
@@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Odebrat šipky",
|
||||
"Show Formulas" => "Zobrazit vzorce",
|
||||
"Error Checking" => "Kontrola chyb",
|
||||
"Circular References" => "Cyklické odkazy",
|
||||
"Evaluate Formula" => "Vyhodnotit vzorec",
|
||||
"Watch Window" => "Okno sledování",
|
||||
"Calculation\nOptions" => "Možnosti\nvýpočtu",
|
||||
@@ -206,6 +207,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Footer" => "Zápatí",
|
||||
"Different First Page" => "Jiná první stránka",
|
||||
"Different Odd & Even Pages" => "Jiné liché a sudé stránky",
|
||||
"Scale with Document" => "Měnit velikost s dokumentem",
|
||||
"Align with Page Margins" => "Zarovnat k okrajům stránky",
|
||||
"Go to Footer" => "Přejít na zápatí",
|
||||
"Go to Header" => "Přejít na záhlaví",
|
||||
"Go to\nFooter" => "Přejít\nna zápatí",
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Pfeile entfernen",
|
||||
"Show Formulas" => "Formeln anzeigen",
|
||||
"Error Checking" => "Fehlerüberprüfung",
|
||||
"Circular References" => "Zirkelbezüge",
|
||||
"Evaluate Formula" => "Formel auswerten",
|
||||
"Watch Window" => "Überwachungsfenster",
|
||||
"Calculation\nOptions" => "Berechnungs-\noptionen",
|
||||
@@ -210,6 +211,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Footer" => "Fußzeile",
|
||||
"Different First Page" => "Erste Seite anders",
|
||||
"Different Odd & Even Pages" => "Gerade und ungerade Seiten anders",
|
||||
"Scale with Document" => "Mit Dokument skalieren",
|
||||
"Align with Page Margins" => "An Seitenrändern ausrichten",
|
||||
"Go to Footer" => "Zur Fußzeile wechseln",
|
||||
"Go to Header" => "Zur Kopfzeile wechseln",
|
||||
"Go to\nFooter" => "Zur\nFußzeile",
|
||||
|
||||
@@ -141,6 +141,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Κατάργηση βελών",
|
||||
"Show Formulas" => "Εμφάνιση τύπων",
|
||||
"Error Checking" => "Έλεγχος σφαλμάτων",
|
||||
"Circular References" => "Κυκλικές αναφορές",
|
||||
"Evaluate Formula" => "Αξιολόγηση τύπου",
|
||||
"Watch Window" => "Παράθυρο παρακολούθησης",
|
||||
"Calculation\nOptions" => "Επιλογές\nυπολογισμού",
|
||||
@@ -202,6 +203,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Footer" => "Υποσέλιδο",
|
||||
"Different First Page" => "Διαφορετική πρώτη σελίδα",
|
||||
"Different Odd & Even Pages" => "Διαφορετικές μονές και ζυγές σελίδες",
|
||||
"Scale with Document" => "Κλιμάκωση με το έγγραφο",
|
||||
"Align with Page Margins" => "Στοίχιση με τα περιθώρια σελίδας",
|
||||
"Go to Footer" => "Μετάβαση στο υποσέλιδο",
|
||||
"Go to Header" => "Μετάβαση στην κεφαλίδα",
|
||||
"Go to\nFooter" => "Μετάβαση στο\nυποσέλιδο",
|
||||
@@ -877,6 +880,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Doughnut" => "Δακτύλιος",
|
||||
"Area" => "Περιοχή",
|
||||
"Stacked Area" => "Σωρευμένη περιοχή",
|
||||
"100% Stacked Area" => "Σωρευμένη περιοχή 100%",
|
||||
"Scatter" => "Διασπορά",
|
||||
"Scatter with Lines" => "Διασπορά με γραμμές",
|
||||
"Radar" => "Ακτινωτό",
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Quitar flechas",
|
||||
"Show Formulas" => "Mostrar fórmulas",
|
||||
"Error Checking" => "Comprobación de errores",
|
||||
"Circular References" => "Referencias circulares",
|
||||
"Evaluate Formula" => "Evaluar fórmula",
|
||||
"Watch Window" => "Ventana Inspección",
|
||||
"Calculation\nOptions" => "Opciones\nde cálculo",
|
||||
@@ -209,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Footer" => "Pie de página",
|
||||
"Different First Page" => "Primera página diferente",
|
||||
"Different Odd & Even Pages" => "Páginas pares e impares diferentes",
|
||||
"Scale with Document" => "Escalar con el documento",
|
||||
"Align with Page Margins" => "Alinear con los márgenes de página",
|
||||
"Go to Footer" => "Ir al pie de página",
|
||||
"Go to Header" => "Ir al encabezado",
|
||||
"Go to\nFooter" => "Ir al pie\nde página",
|
||||
|
||||
@@ -4,6 +4,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Different First Page" => "Première page différente",
|
||||
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
|
||||
"Scale with Document" => "Mettre à l'échelle avec le document",
|
||||
"Align with Page Margins" => "Aligner sur les marges de la page",
|
||||
"Paste" => "Coller",
|
||||
"Paste Values" => "Coller les valeurs",
|
||||
"Paste Formulas" => "Coller les formules",
|
||||
@@ -154,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Supprimer les flèches",
|
||||
"Show Formulas" => "Afficher les formules",
|
||||
"Error Checking" => "Vérification des erreurs",
|
||||
"Circular References" => "Références circulaires",
|
||||
"Evaluate Formula" => "Évaluation de formule",
|
||||
"Watch Window" => "Fenêtre Espion",
|
||||
"Calculation\nOptions" => "Options de\ncalcul",
|
||||
|
||||
@@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Rimuovi frecce",
|
||||
"Show Formulas" => "Mostra formule",
|
||||
"Error Checking" => "Controllo errori",
|
||||
"Circular References" => "Riferimenti circolari",
|
||||
"Evaluate Formula" => "Valuta formula",
|
||||
"Watch Window" => "Finestra controlli",
|
||||
"Calculation\nOptions" => "Opzioni di\ncalcolo",
|
||||
@@ -327,6 +328,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Vai a intestazione",
|
||||
"Different First Page" => "Prima pagina diversa",
|
||||
"Different Odd & Even Pages" => "Pagine pari e dispari diverse",
|
||||
"Scale with Document" => "Ridimensiona con il documento",
|
||||
"Align with Page Margins" => "Allinea ai margini della pagina",
|
||||
"Go to Footer" => "Vai a piè di pagina",
|
||||
"Ruler" => "Righello",
|
||||
// File › Backstage view.
|
||||
@@ -761,6 +764,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Doughnut" => "Anello",
|
||||
"Area" => "Area",
|
||||
"Stacked Area" => "Area in pila",
|
||||
"100% Stacked Area" => "Area in pila 100%",
|
||||
"Scatter" => "Dispersione",
|
||||
"Scatter with Lines" => "Dispersione con linee",
|
||||
"Radar" => "Radar",
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Remove Arrows", "矢印の解除"),
|
||||
("Show Formulas", "数式の表示"),
|
||||
("Error Checking", "エラーチェック"),
|
||||
("Circular References", "循環参照"),
|
||||
("Evaluate Formula", "数式の検証"),
|
||||
("Watch Window", "ウォッチ ウィンドウ"),
|
||||
("Calculation\nOptions", "計算方法の設定"),
|
||||
@@ -331,6 +332,8 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Go to Header", "ヘッダーに移動"),
|
||||
("Different First Page", "先頭ページのみ別指定"),
|
||||
("Different Odd & Even Pages", "奇数/偶数ページ別指定"),
|
||||
("Scale with Document", "文書に合わせて拡大/縮小"),
|
||||
("Align with Page Margins", "ページ余白に合わせる"),
|
||||
("Go to Footer", "フッターに移動"),
|
||||
("Ruler", "ルーラー"),
|
||||
("File", "ファイル"),
|
||||
@@ -770,6 +773,7 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
|
||||
("Doughnut", "ドーナツ"),
|
||||
("Area", "面"),
|
||||
("Stacked Area", "積み上げ面"),
|
||||
("100% Stacked Area", "100% 積み上げ面"),
|
||||
("Scatter", "散布図"),
|
||||
("Scatter with Lines", "直線付き散布図"),
|
||||
("Radar", "レーダー"),
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "화살표 제거",
|
||||
"Show Formulas" => "수식 표시",
|
||||
"Error Checking" => "오류 검사",
|
||||
"Circular References" => "순환 참조",
|
||||
"Evaluate Formula" => "수식 계산",
|
||||
"Watch Window" => "감시 창",
|
||||
"Calculation\nOptions" => "계산\n옵션",
|
||||
@@ -332,6 +333,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "머리글로 이동",
|
||||
"Different First Page" => "첫 페이지를 다르게 지정",
|
||||
"Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정",
|
||||
"Scale with Document" => "문서에 맞게 배율 조정",
|
||||
"Align with Page Margins" => "페이지 여백에 맞추기",
|
||||
"Go to Footer" => "바닥글로 이동",
|
||||
"Ruler" => "눈금자",
|
||||
// File › Backstage view.
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Pijlen verwijderen",
|
||||
"Show Formulas" => "Formules weergeven",
|
||||
"Error Checking" => "Foutopsporing",
|
||||
"Circular References" => "Kringverwijzingen",
|
||||
"Evaluate Formula" => "Formule evalueren",
|
||||
"Watch Window" => "Bewakingsvenster",
|
||||
"Calculation\nOptions" => "Berekenings-\nopties",
|
||||
@@ -326,6 +327,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Naar koptekst",
|
||||
"Different First Page" => "Eerste pagina afwijkend",
|
||||
"Different Odd & Even Pages" => "Even en oneven pagina's afwijkend",
|
||||
"Scale with Document" => "Meeschalen met document",
|
||||
"Align with Page Margins" => "Uitlijnen op paginamarges",
|
||||
"Go to Footer" => "Naar voettekst",
|
||||
"Ruler" => "Liniaal",
|
||||
"File" => "Bestand",
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Usuń strzałki",
|
||||
"Show Formulas" => "Pokaż formuły",
|
||||
"Error Checking" => "Sprawdzanie błędów",
|
||||
"Circular References" => "Odwołania cykliczne",
|
||||
"Evaluate Formula" => "Szacuj formułę",
|
||||
"Watch Window" => "Okno czujki",
|
||||
"Calculation\nOptions" => "Opcje\nobliczania",
|
||||
@@ -322,6 +323,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Przejdź do nagłówka",
|
||||
"Different First Page" => "Inna pierwsza strona",
|
||||
"Different Odd & Even Pages" => "Inne strony nieparzyste i parzyste",
|
||||
"Scale with Document" => "Skaluj z dokumentem",
|
||||
"Align with Page Margins" => "Wyrównaj do marginesów strony",
|
||||
"Go to Footer" => "Przejdź do stopki",
|
||||
"Ruler" => "Linijka",
|
||||
"File" => "Plik",
|
||||
@@ -755,6 +758,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Doughnut" => "Pierścieniowy",
|
||||
"Area" => "Warstwowy",
|
||||
"Stacked Area" => "Warstwowy skumulowany",
|
||||
"100% Stacked Area" => "Warstwowy 100% skumulowany",
|
||||
"Scatter" => "Punktowy",
|
||||
"Scatter with Lines" => "Punktowy z liniami",
|
||||
"Radar" => "Radarowy",
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Remover Setas",
|
||||
"Show Formulas" => "Exibir Fórmulas",
|
||||
"Error Checking" => "Verificação de Erros",
|
||||
"Circular References" => "Referências Circulares",
|
||||
"Evaluate Formula" => "Avaliar Fórmula",
|
||||
"Watch Window" => "Janela de Inspeção",
|
||||
"Calculation\nOptions" => "Opções de\nCálculo",
|
||||
@@ -328,6 +329,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Ir para o Cabeçalho",
|
||||
"Different First Page" => "Primeira Página Diferente",
|
||||
"Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes",
|
||||
"Scale with Document" => "Dimensionar com o Documento",
|
||||
"Align with Page Margins" => "Alinhar com as Margens da Página",
|
||||
"Go to Footer" => "Ir para o Rodapé",
|
||||
"Ruler" => "Régua",
|
||||
// File › Backstage view.
|
||||
@@ -772,6 +775,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Doughnut" => "Rosca",
|
||||
"Area" => "Área",
|
||||
"Stacked Area" => "Área Empilhada",
|
||||
"100% Stacked Area" => "Área 100% Empilhada",
|
||||
"Scatter" => "Dispersão",
|
||||
"Scatter with Lines" => "Dispersão com Linhas",
|
||||
"Radar" => "Radar",
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Eliminare săgeți",
|
||||
"Show Formulas" => "Afișare formule",
|
||||
"Error Checking" => "Verificare erori",
|
||||
"Circular References" => "Referințe circulare",
|
||||
"Evaluate Formula" => "Evaluare formulă",
|
||||
"Watch Window" => "Fereastră de urmărire",
|
||||
"Calculation\nOptions" => "Opțiuni de\ncalcul",
|
||||
@@ -329,6 +330,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Salt la antet",
|
||||
"Different First Page" => "Prima pagină diferită",
|
||||
"Different Odd & Even Pages" => "Pagini pare și impare diferite",
|
||||
"Scale with Document" => "Scalare cu documentul",
|
||||
"Align with Page Margins" => "Aliniere la marginile paginii",
|
||||
"Go to Footer" => "Salt la subsol",
|
||||
"Ruler" => "Riglă",
|
||||
"File" => "Fișier",
|
||||
@@ -761,6 +764,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Doughnut" => "Inel",
|
||||
"Area" => "Arie",
|
||||
"Stacked Area" => "Arie stivuită",
|
||||
"100% Stacked Area" => "Arie 100% stivuită",
|
||||
"Scatter" => "Dispersie",
|
||||
"Scatter with Lines" => "Dispersie cu linii",
|
||||
"Radar" => "Radar",
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Убрать стрелки",
|
||||
"Show Formulas" => "Показать формулы",
|
||||
"Error Checking" => "Проверка ошибок",
|
||||
"Circular References" => "Циклические ссылки",
|
||||
"Evaluate Formula" => "Вычислить формулу",
|
||||
"Watch Window" => "Окно контрольных значений",
|
||||
"Calculation\nOptions" => "Параметры\nвычислений",
|
||||
@@ -332,6 +333,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Перейти к верхнему колонтитулу",
|
||||
"Different First Page" => "Особый колонтитул для первой страницы",
|
||||
"Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц",
|
||||
"Scale with Document" => "Масштабировать вместе с документом",
|
||||
"Align with Page Margins" => "Выровнять по полям страницы",
|
||||
"Go to Footer" => "Перейти к нижнему колонтитулу",
|
||||
"Ruler" => "Линейка",
|
||||
// File › Backstage view.
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Ukloni strelice",
|
||||
"Show Formulas" => "Prikaži formule",
|
||||
"Error Checking" => "Provera grešaka",
|
||||
"Circular References" => "Kružne reference",
|
||||
"Evaluate Formula" => "Proceni formulu",
|
||||
"Watch Window" => "Prozor za praćenje",
|
||||
"Calculation\nOptions" => "Opcije\nizračunavanja",
|
||||
@@ -325,6 +326,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Idi na zaglavlje",
|
||||
"Different First Page" => "Drugačija prva stranica",
|
||||
"Different Odd & Even Pages" => "Drugačije neparne i parne stranice",
|
||||
"Scale with Document" => "Skaliraj sa dokumentom",
|
||||
"Align with Page Margins" => "Poravnaj sa marginama stranice",
|
||||
"Go to Footer" => "Idi na podnožje",
|
||||
"Ruler" => "Lenjir",
|
||||
"File" => "Datoteka",
|
||||
|
||||
@@ -144,6 +144,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Okları Kaldır",
|
||||
"Show Formulas" => "Formülleri Göster",
|
||||
"Error Checking" => "Hata Denetimi",
|
||||
"Circular References" => "Döngüsel Başvurular",
|
||||
"Evaluate Formula" => "Formülü Değerlendir",
|
||||
"Watch Window" => "Gözcü Penceresi",
|
||||
"Calculation\nOptions" => "Hesaplama\nSeçenekleri",
|
||||
@@ -205,6 +206,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Footer" => "Altbilgi",
|
||||
"Different First Page" => "İlk Sayfada Farklı",
|
||||
"Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı",
|
||||
"Scale with Document" => "Belgeyle Ölçeklendir",
|
||||
"Align with Page Margins" => "Sayfa Kenar Boşluklarıyla Hizala",
|
||||
"Go to Footer" => "Altbilgiye Git",
|
||||
"Go to Header" => "Üstbilgiye Git",
|
||||
"Go to\nFooter" => "Altbilgiye\nGit",
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Прибрати стрілки",
|
||||
"Show Formulas" => "Показати формули",
|
||||
"Error Checking" => "Перевірка помилок",
|
||||
"Circular References" => "Циклічні посилання",
|
||||
"Evaluate Formula" => "Обчислити формулу",
|
||||
"Watch Window" => "Окно контрольних значень",
|
||||
"Calculation\nOptions" => "Параметри\nобчислень",
|
||||
@@ -325,6 +326,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Перейти до верхнього колонтитула",
|
||||
"Different First Page" => "Окремий колонтитул для першої сторінки",
|
||||
"Different Odd & Even Pages" => "Різні колонтитули для парних і непарних сторінок",
|
||||
"Scale with Document" => "Масштабувати разом із документом",
|
||||
"Align with Page Margins" => "Вирівняти за полями сторінки",
|
||||
"Go to Footer" => "Перейти до нижнього колонтитула",
|
||||
"Ruler" => "Лінійка",
|
||||
// File › Backstage view.
|
||||
@@ -758,6 +761,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Doughnut" => "Doughnut",
|
||||
"Area" => "Area",
|
||||
"Stacked Area" => "Stacked Area",
|
||||
"100% Stacked Area" => "100% Stacked Area",
|
||||
"Scatter" => "Scatter",
|
||||
"Scatter with Lines" => "Scatter with Lines",
|
||||
"Radar" => "Radar",
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Bỏ mũi tên",
|
||||
"Show Formulas" => "Hiện công thức",
|
||||
"Error Checking" => "Kiểm tra lỗi",
|
||||
"Circular References" => "Tham chiếu vòng",
|
||||
"Evaluate Formula" => "Đánh giá công thức",
|
||||
"Watch Window" => "Cửa sổ theo dõi",
|
||||
"Calculation\nOptions" => "Tùy chọn\ntính toán",
|
||||
@@ -330,6 +331,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "Đi tới đầu trang",
|
||||
"Different First Page" => "Trang đầu khác",
|
||||
"Different Odd & Even Pages" => "Trang chẵn lẻ khác nhau",
|
||||
"Scale with Document" => "Co giãn theo tài liệu",
|
||||
"Align with Page Margins" => "Căn theo lề trang",
|
||||
"Go to Footer" => "Đi tới chân trang",
|
||||
"Ruler" => "Thước",
|
||||
// File › Backstage view.
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "删除箭头",
|
||||
"Show Formulas" => "显示公式",
|
||||
"Error Checking" => "错误检查",
|
||||
"Circular References" => "循环引用",
|
||||
"Evaluate Formula" => "公式求值",
|
||||
"Watch Window" => "监视窗口",
|
||||
"Calculation\nOptions" => "计算\n选项",
|
||||
@@ -330,6 +331,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Go to Header" => "转至页眉",
|
||||
"Different First Page" => "首页不同",
|
||||
"Different Odd & Even Pages" => "奇偶页不同",
|
||||
"Scale with Document" => "随文档缩放",
|
||||
"Align with Page Margins" => "与页边距对齐",
|
||||
"Go to Footer" => "转至页脚",
|
||||
"Ruler" => "标尺",
|
||||
// File › Backstage view.
|
||||
|
||||
+154
-121
@@ -1,9 +1,13 @@
|
||||
//! GridCraft's icon set, drawn in code on a 16×16 design grid (original artwork; see
|
||||
//! ATTRIBUTION.md). Large ribbon icons use the same drawings at 32×32 with accent colours.
|
||||
//! Accent and paper colours come from the theme tokens, so icons follow light and dark mode;
|
||||
//! icons placed on the sheet ([`paint_on_sheet`]) keep the light colours, as the sheet does.
|
||||
|
||||
use egui::epaint::{CubicBezierShape, PathShape, PathStroke};
|
||||
use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};
|
||||
|
||||
use crate::theme::{Cat, Tokens};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Icon {
|
||||
Paste,
|
||||
@@ -184,25 +188,57 @@ impl G<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent palette for icons (our own).
|
||||
pub const GREEN: Color32 = Color32::from_rgb(0x10, 0x7C, 0x41);
|
||||
pub const BLUE: Color32 = Color32::from_rgb(0x2B, 0x7C, 0xD3);
|
||||
pub const RED: Color32 = Color32::from_rgb(0xD1, 0x3B, 0x2F);
|
||||
pub const YELLOW: Color32 = Color32::from_rgb(0xF2, 0xC3, 0x2E);
|
||||
pub const ORANGE: Color32 = Color32::from_rgb(0xE8, 0x7D, 0x2B);
|
||||
pub const PURPLE: Color32 = Color32::from_rgb(0x88, 0x55, 0xC8);
|
||||
/// The colours an icon draws with besides its main colour: the paper inside documents and cells,
|
||||
/// and the category palette for its coloured parts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Palette {
|
||||
paper: Color32,
|
||||
cat: [Color32; 8],
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`.
|
||||
impl Palette {
|
||||
fn of(t: &Tokens) -> Palette {
|
||||
Palette { paper: t.paper, cat: t.cat }
|
||||
}
|
||||
|
||||
fn cat(&self, c: Cat) -> Color32 {
|
||||
self.cat.get(c as usize).copied().unwrap_or(Color32::GRAY)
|
||||
}
|
||||
|
||||
/// A pale wash of a category colour over the paper (cell fills inside icons).
|
||||
fn wash(&self, c: Cat) -> Color32 {
|
||||
self.cat(c).gamma_multiply(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
|
||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
|
||||
}
|
||||
|
||||
/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette.
|
||||
pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::light()));
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with palette `pal`.
|
||||
fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
||||
let side = rect.width().min(rect.height());
|
||||
let r = Rect::from_center_size(rect.center(), vec2(side, side));
|
||||
let g = G { p, r, s: side / 16.0 };
|
||||
let light = c.gamma_multiply(0.45);
|
||||
let paper = pal.paper;
|
||||
let blue = pal.cat(Cat::Blue);
|
||||
let green = pal.cat(Cat::Green);
|
||||
let red = pal.cat(Cat::Red);
|
||||
let orange = pal.cat(Cat::Orange);
|
||||
let purple = pal.cat(Cat::Purple);
|
||||
let yellow = pal.cat(Cat::Amber);
|
||||
match icon {
|
||||
Icon::Paste => {
|
||||
g.rect(3.0, 2.5, 9.0, 12.0, Some(Color32::from_rgb(0xC8, 0x9B, 0x5C)), None);
|
||||
g.rect(5.0, 1.5, 5.0, 2.5, Some(Color32::from_rgb(0x8A, 0x8A, 0x8A)), None);
|
||||
g.rect(6.5, 6.0, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(3.0, 2.5, 9.0, 12.0, Some(yellow), None);
|
||||
g.rect(5.0, 1.5, 5.0, 2.5, Some(pal.cat(Cat::Slate)), None);
|
||||
g.rect(6.5, 6.0, 7.5, 9.0, Some(paper), Some(c));
|
||||
for y in [8.5, 10.5, 12.5] {
|
||||
g.stroke(&[(8.0, y), (12.5, y)], light);
|
||||
}
|
||||
@@ -214,11 +250,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.stroke(&[(9.7, 10.6), (4.5, 2.0)], c);
|
||||
}
|
||||
Icon::Copy => {
|
||||
g.rect(2.5, 2.5, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(6.0, 5.5, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.5, 2.5, 7.5, 9.0, Some(paper), Some(c));
|
||||
g.rect(6.0, 5.5, 7.5, 9.0, Some(paper), Some(c));
|
||||
}
|
||||
Icon::Brush => {
|
||||
g.rect(3.0, 2.0, 10.0, 4.0, Some(YELLOW), Some(c));
|
||||
g.rect(3.0, 2.0, 10.0, 4.0, Some(yellow), Some(c));
|
||||
g.stroke(&[(8.0, 6.0), (8.0, 8.5), (5.0, 8.5), (5.0, 10.0)], c);
|
||||
g.rect(4.0, 10.0, 2.0, 4.5, Some(c), None);
|
||||
}
|
||||
@@ -259,7 +295,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.line(&[(2.5, 2.5), (13.5, 2.5), (13.5, 13.5), (2.5, 13.5), (2.5, 2.5)], c, if icon == Icon::BorderThick { 2.2 } else { 1.2 });
|
||||
}
|
||||
Icon::Fill => {
|
||||
g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], Color32::WHITE);
|
||||
g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], paper);
|
||||
g.line(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0), (3.0, 7.0)], c, 1.1);
|
||||
g.circle(13.5, 10.0, 1.2, Some(c), None);
|
||||
}
|
||||
@@ -297,16 +333,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.stroke(&[(x0, *y), (x1, *y)], c);
|
||||
}
|
||||
if icon == Icon::IndentInc {
|
||||
g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], BLUE);
|
||||
g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], blue);
|
||||
}
|
||||
if icon == Icon::IndentDec {
|
||||
g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], BLUE);
|
||||
g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], blue);
|
||||
}
|
||||
}
|
||||
Icon::Orientation => {
|
||||
g.text(6.0, 9.0, "ab", 8.5, c, false, false);
|
||||
g.stroke(&[(9.0, 13.0), (14.0, 3.0)], BLUE);
|
||||
g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], BLUE);
|
||||
g.stroke(&[(9.0, 13.0), (14.0, 3.0)], blue);
|
||||
g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], blue);
|
||||
}
|
||||
Icon::Wrap => {
|
||||
g.stroke(&[(2.0, 4.0), (14.0, 4.0)], c);
|
||||
@@ -315,16 +351,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
[g.pt(12.0, 8.0), g.pt(15.0, 8.0), g.pt(15.0, 12.0), g.pt(11.0, 12.0)],
|
||||
false,
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.1, BLUE),
|
||||
Stroke::new(1.1, blue),
|
||||
));
|
||||
g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], BLUE);
|
||||
g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], blue);
|
||||
g.stroke(&[(2.0, 12.0), (7.0, 12.0)], c);
|
||||
}
|
||||
Icon::Merge => {
|
||||
g.rect(1.5, 3.5, 13.0, 9.0, None, Some(c));
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], BLUE);
|
||||
g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], BLUE);
|
||||
g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], BLUE);
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], blue);
|
||||
g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], blue);
|
||||
g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], blue);
|
||||
}
|
||||
Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, false, false),
|
||||
Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, false, false),
|
||||
@@ -332,69 +368,69 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
Icon::DecInc => {
|
||||
g.text(5.0, 5.0, ".0", 6.5, c, false, false);
|
||||
g.text(10.0, 11.0, ".00", 6.5, c, false, false);
|
||||
g.stroke(&[(2.0, 12.0), (5.0, 12.0)], BLUE);
|
||||
g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], BLUE);
|
||||
g.stroke(&[(2.0, 12.0), (5.0, 12.0)], blue);
|
||||
g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], blue);
|
||||
}
|
||||
Icon::DecDec => {
|
||||
g.text(6.0, 5.0, ".00", 6.5, c, false, false);
|
||||
g.text(11.0, 11.0, ".0", 6.5, c, false, false);
|
||||
g.stroke(&[(2.0, 12.0), (5.5, 12.0)], BLUE);
|
||||
g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], BLUE);
|
||||
g.stroke(&[(2.0, 12.0), (5.5, 12.0)], blue);
|
||||
g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], blue);
|
||||
}
|
||||
Icon::CondFormat => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(Color32::WHITE), Some(light));
|
||||
g.rect(3.0, 3.5, 4.0, 2.5, Some(RED), None);
|
||||
g.rect(3.0, 7.0, 7.0, 2.5, Some(YELLOW), None);
|
||||
g.rect(3.0, 10.5, 10.0, 2.5, Some(BLUE), None);
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(paper), Some(light));
|
||||
g.rect(3.0, 3.5, 4.0, 2.5, Some(red), None);
|
||||
g.rect(3.0, 7.0, 7.0, 2.5, Some(yellow), None);
|
||||
g.rect(3.0, 10.5, 10.0, 2.5, Some(blue), None);
|
||||
}
|
||||
Icon::FormatTable => {
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(light));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(BLUE), None);
|
||||
g.rect(2.0, 8.0, 12.0, 2.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(light));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(blue), None);
|
||||
g.rect(2.0, 8.0, 12.0, 2.5, Some(pal.wash(Cat::Blue)), None);
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
||||
g.stroke(&[(10.0, 2.5), (10.0, 13.5)], light);
|
||||
}
|
||||
Icon::CellStyles => {
|
||||
g.rect(2.0, 3.0, 12.0, 4.5, Some(Color32::from_rgb(0xC6, 0xEF, 0xCE)), Some(light));
|
||||
g.rect(2.0, 8.5, 12.0, 4.5, Some(Color32::from_rgb(0xFF, 0xC7, 0xCE)), Some(light));
|
||||
g.stroke(&[(4.0, 5.2), (9.0, 5.2)], GREEN);
|
||||
g.stroke(&[(4.0, 10.7), (9.0, 10.7)], RED);
|
||||
g.rect(2.0, 3.0, 12.0, 4.5, Some(pal.wash(Cat::Green)), Some(light));
|
||||
g.rect(2.0, 8.5, 12.0, 4.5, Some(pal.wash(Cat::Red)), Some(light));
|
||||
g.stroke(&[(4.0, 5.2), (9.0, 5.2)], green);
|
||||
g.stroke(&[(4.0, 10.7), (9.0, 10.7)], red);
|
||||
}
|
||||
Icon::Insert | Icon::Delete | Icon::Format => {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::WHITE), Some(light));
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(paper), Some(light));
|
||||
g.stroke(&[(2.0, 6.5), (14.0, 6.5)], light);
|
||||
g.stroke(&[(2.0, 10.0), (14.0, 10.0)], light);
|
||||
g.stroke(&[(6.0, 3.0), (6.0, 13.0)], light);
|
||||
match icon {
|
||||
Icon::Insert => {
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(BLUE));
|
||||
g.line(&[(1.0, 8.2), (4.5, 8.2)], GREEN, 1.6);
|
||||
g.line(&[(2.75, 6.5), (2.75, 10.0)], GREEN, 1.6);
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), Some(blue));
|
||||
g.line(&[(1.0, 8.2), (4.5, 8.2)], green, 1.6);
|
||||
g.line(&[(2.75, 6.5), (2.75, 10.0)], green, 1.6);
|
||||
}
|
||||
Icon::Delete => {
|
||||
g.line(&[(8.0, 6.0), (13.0, 11.0)], RED, 1.6);
|
||||
g.line(&[(13.0, 6.0), (8.0, 11.0)], RED, 1.6);
|
||||
g.line(&[(8.0, 6.0), (13.0, 11.0)], red, 1.6);
|
||||
g.line(&[(13.0, 6.0), (8.0, 11.0)], red, 1.6);
|
||||
}
|
||||
_ => {
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, false, false),
|
||||
Icon::FillDown => {
|
||||
g.rect(4.0, 2.0, 8.0, 12.0, Some(Color32::WHITE), Some(light));
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 11.0)], BLUE);
|
||||
g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], BLUE);
|
||||
g.rect(4.0, 2.0, 8.0, 12.0, Some(paper), Some(light));
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 11.0)], blue);
|
||||
g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], blue);
|
||||
}
|
||||
Icon::Eraser => {
|
||||
g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], Color32::from_rgb(0xE9, 0x8A, 0xB8));
|
||||
g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], Color32::from_rgb(0xB6, 0x4E, 0x86));
|
||||
g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], red.gamma_multiply(0.55));
|
||||
g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], red);
|
||||
}
|
||||
Icon::SortFilter => {
|
||||
g.text(4.5, 4.5, "A", 6.0, c, false, false);
|
||||
g.text(4.5, 11.5, "Z", 6.0, c, false, false);
|
||||
g.stroke(&[(8.0, 3.0), (8.0, 13.0)], BLUE);
|
||||
g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], BLUE);
|
||||
g.stroke(&[(8.0, 3.0), (8.0, 13.0)], blue);
|
||||
g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], blue);
|
||||
g.poly(&[(10.0, 3.0), (15.0, 3.0), (13.0, 7.5), (13.0, 12.0), (12.0, 11.0), (12.0, 7.5)], light);
|
||||
}
|
||||
Icon::Find | Icon::Search | Icon::Zoom => {
|
||||
@@ -466,45 +502,45 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
}
|
||||
Icon::PageBreak => {
|
||||
g.rect(2.5, 2.0, 11.0, 12.0, None, Some(c));
|
||||
g.line(&[(2.5, 8.0), (13.5, 8.0)], BLUE, 1.6);
|
||||
g.line(&[(2.5, 8.0), (13.5, 8.0)], blue, 1.6);
|
||||
}
|
||||
Icon::Table | Icon::PivotTable => {
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { BLUE } else { GREEN }), None);
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(c));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { blue } else { green }), None);
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
||||
g.stroke(&[(2.0, 9.5), (14.0, 9.5)], light);
|
||||
if icon == Icon::PivotTable {
|
||||
g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], ORANGE);
|
||||
g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], orange);
|
||||
}
|
||||
}
|
||||
Icon::Chart | Icon::ChartBar => {
|
||||
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], c);
|
||||
g.rect(3.0, 8.0, 2.5, 6.0, Some(BLUE), None);
|
||||
g.rect(6.8, 4.0, 2.5, 10.0, Some(GREEN), None);
|
||||
g.rect(10.6, 6.0, 2.5, 8.0, Some(ORANGE), None);
|
||||
g.rect(3.0, 8.0, 2.5, 6.0, Some(blue), None);
|
||||
g.rect(6.8, 4.0, 2.5, 10.0, Some(green), None);
|
||||
g.rect(10.6, 6.0, 2.5, 8.0, Some(orange), None);
|
||||
}
|
||||
Icon::ChartLine | Icon::Sparkline => {
|
||||
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], light);
|
||||
g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], BLUE, 1.5);
|
||||
g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], blue, 1.5);
|
||||
}
|
||||
Icon::ChartPie => {
|
||||
g.circle(8.0, 8.0, 6.0, Some(BLUE), None);
|
||||
g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], ORANGE);
|
||||
g.circle(8.0, 8.0, 6.0, Some(blue), None);
|
||||
g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], orange);
|
||||
}
|
||||
Icon::ChartScatter => {
|
||||
g.stroke(&[(2.0, 2.0), (2.0, 14.0), (14.0, 14.0)], c);
|
||||
for (x, y) in [(5.0, 11.0), (7.0, 8.0), (9.5, 9.5), (11.0, 5.0), (13.0, 4.0)] {
|
||||
g.circle(x, y, 1.1, Some(BLUE), None);
|
||||
g.circle(x, y, 1.1, Some(blue), None);
|
||||
}
|
||||
}
|
||||
Icon::Picture => {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(c));
|
||||
g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], GREEN);
|
||||
g.circle(11.0, 5.5, 1.2, Some(YELLOW), None);
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(pal.wash(Cat::Blue)), Some(c));
|
||||
g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], green);
|
||||
g.circle(11.0, 5.5, 1.2, Some(yellow), None);
|
||||
}
|
||||
Icon::Shapes => {
|
||||
g.circle(5.5, 5.5, 3.5, Some(BLUE), None);
|
||||
g.rect(7.0, 7.0, 7.0, 7.0, Some(ORANGE), None);
|
||||
g.circle(5.5, 5.5, 3.5, Some(blue), None);
|
||||
g.rect(7.0, 7.0, 7.0, 7.0, Some(orange), None);
|
||||
}
|
||||
Icon::Link => {
|
||||
g.line(&[(6.0, 10.0), (10.0, 6.0)], c, 1.2);
|
||||
@@ -512,10 +548,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.rect(8.0, 4.0, 6.0, 4.0, None, Some(c));
|
||||
}
|
||||
Icon::Comment | Icon::Note => {
|
||||
g.poly(
|
||||
&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)],
|
||||
if icon == Icon::Note { Color32::from_rgb(0xFF, 0xF2, 0xA8) } else { Color32::WHITE },
|
||||
);
|
||||
g.poly(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)], if icon == Icon::Note { pal.wash(Cat::Amber) } else { paper });
|
||||
g.line(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (7.0, 11.0), (4.0, 14.0), (4.0, 11.0), (2.0, 11.0), (2.0, 3.0)], c, 1.1);
|
||||
}
|
||||
Icon::TextBox => {
|
||||
@@ -529,10 +562,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.text(8.0, 8.0, "ab", 8.0, c, false, false);
|
||||
}
|
||||
Icon::Trace => {
|
||||
g.circle(4.0, 12.0, 1.6, Some(BLUE), None);
|
||||
g.circle(12.0, 4.0, 1.6, Some(BLUE), None);
|
||||
g.line(&[(5.0, 11.0), (11.0, 5.0)], BLUE, 1.2);
|
||||
g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], BLUE);
|
||||
g.circle(4.0, 12.0, 1.6, Some(blue), None);
|
||||
g.circle(12.0, 4.0, 1.6, Some(blue), None);
|
||||
g.line(&[(5.0, 11.0), (11.0, 5.0)], blue, 1.2);
|
||||
g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], blue);
|
||||
}
|
||||
Icon::Calc => {
|
||||
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
||||
@@ -546,23 +579,23 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
let (a, b) = if icon == Icon::SortAsc { ("A", "Z") } else { ("Z", "A") };
|
||||
g.text(5.0, 4.5, a, 6.5, c, false, false);
|
||||
g.text(5.0, 11.5, b, 6.5, c, false, false);
|
||||
g.stroke(&[(11.0, 2.5), (11.0, 13.0)], BLUE);
|
||||
g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], BLUE);
|
||||
g.stroke(&[(11.0, 2.5), (11.0, 13.0)], blue);
|
||||
g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], blue);
|
||||
}
|
||||
Icon::Duplicates => {
|
||||
g.rect(2.0, 2.0, 9.0, 4.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(5.0, 7.0, 9.0, 4.0, Some(Color32::WHITE), Some(c));
|
||||
g.line(&[(9.0, 12.0), (14.0, 15.0)], RED, 1.4);
|
||||
g.line(&[(14.0, 12.0), (9.0, 15.0)], RED, 1.4);
|
||||
g.rect(2.0, 2.0, 9.0, 4.0, Some(paper), Some(c));
|
||||
g.rect(5.0, 7.0, 9.0, 4.0, Some(paper), Some(c));
|
||||
g.line(&[(9.0, 12.0), (14.0, 15.0)], red, 1.4);
|
||||
g.line(&[(14.0, 12.0), (9.0, 15.0)], red, 1.4);
|
||||
}
|
||||
Icon::TextColumns => {
|
||||
g.rect(1.5, 3.0, 5.5, 10.0, None, Some(c));
|
||||
g.rect(9.0, 3.0, 5.5, 10.0, None, Some(c));
|
||||
g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], BLUE);
|
||||
g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], blue);
|
||||
}
|
||||
Icon::Validation => {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
|
||||
g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], GREEN, 1.4);
|
||||
g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], green, 1.4);
|
||||
}
|
||||
Icon::Group | Icon::Ungroup | Icon::Subtotal => {
|
||||
g.stroke(&[(3.0, 3.0), (3.0, 13.0), (6.0, 13.0)], c);
|
||||
@@ -570,59 +603,59 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.stroke(&[(7.0, y), (14.0, y)], light);
|
||||
}
|
||||
match icon {
|
||||
Icon::Group => g.text(3.0, 2.5, "+", 6.0, BLUE, true, false),
|
||||
Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, BLUE, true, false),
|
||||
_ => g.text(11.0, 13.0, "Σ", 6.0, BLUE, false, false),
|
||||
Icon::Group => g.text(3.0, 2.5, "+", 6.0, blue, true, false),
|
||||
Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, blue, true, false),
|
||||
_ => g.text(11.0, 13.0, "Σ", 6.0, blue, false, false),
|
||||
}
|
||||
}
|
||||
Icon::Lock => {
|
||||
g.rect(3.5, 7.0, 9.0, 7.0, Some(YELLOW), Some(c));
|
||||
g.rect(3.5, 7.0, 9.0, 7.0, Some(yellow), Some(c));
|
||||
g.line(&[(5.5, 7.0), (5.5, 4.5), (8.0, 2.5), (10.5, 4.5), (10.5, 7.0)], c, 1.2);
|
||||
}
|
||||
Icon::Spell => {
|
||||
g.text(7.0, 6.0, "abc", 7.0, c, false, false);
|
||||
g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], GREEN, 1.4);
|
||||
g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], green, 1.4);
|
||||
}
|
||||
Icon::Freeze => {
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, None, Some(c));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.rect(2.0, 2.5, 3.5, 11.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.line(&[(2.0, 5.5), (14.0, 5.5)], BLUE, 1.2);
|
||||
g.line(&[(5.5, 2.5), (5.5, 13.5)], BLUE, 1.2);
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(pal.wash(Cat::Blue)), None);
|
||||
g.rect(2.0, 2.5, 3.5, 11.0, Some(pal.wash(Cat::Blue)), None);
|
||||
g.line(&[(2.0, 5.5), (14.0, 5.5)], blue, 1.2);
|
||||
g.line(&[(5.5, 2.5), (5.5, 13.5)], blue, 1.2);
|
||||
}
|
||||
Icon::Theme => {
|
||||
g.rect(2.0, 2.0, 5.5, 5.5, Some(BLUE), None);
|
||||
g.rect(8.5, 2.0, 5.5, 5.5, Some(ORANGE), None);
|
||||
g.rect(2.0, 8.5, 5.5, 5.5, Some(GREEN), None);
|
||||
g.rect(8.5, 8.5, 5.5, 5.5, Some(PURPLE), None);
|
||||
g.rect(2.0, 2.0, 5.5, 5.5, Some(blue), None);
|
||||
g.rect(8.5, 2.0, 5.5, 5.5, Some(orange), None);
|
||||
g.rect(2.0, 8.5, 5.5, 5.5, Some(green), None);
|
||||
g.rect(8.5, 8.5, 5.5, 5.5, Some(purple), None);
|
||||
}
|
||||
Icon::Margins => {
|
||||
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
||||
g.rect(5.0, 3.5, 6.0, 9.0, None, Some(BLUE));
|
||||
g.rect(5.0, 3.5, 6.0, 9.0, None, Some(blue));
|
||||
}
|
||||
Icon::Orient => {
|
||||
g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c));
|
||||
g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE));
|
||||
g.rect(6.0, 2.0, 8.0, 6.0, Some(paper), Some(blue));
|
||||
}
|
||||
Icon::SheetRtl => {
|
||||
// A sheet with an arrow running right to left.
|
||||
g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c));
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light);
|
||||
g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light);
|
||||
g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4);
|
||||
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE);
|
||||
g.line(&[(13.5, 13.0), (4.5, 13.0)], blue, 1.4);
|
||||
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], blue);
|
||||
}
|
||||
Icon::PrintArea => {
|
||||
g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light));
|
||||
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4);
|
||||
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], blue, 1.4);
|
||||
}
|
||||
Icon::Script => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
|
||||
g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], GREEN, 1.3);
|
||||
g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], green, 1.3);
|
||||
g.stroke(&[(8.0, 10.5), (11.5, 10.5)], c);
|
||||
}
|
||||
Icon::Pen => {
|
||||
g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], BLUE);
|
||||
g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], blue);
|
||||
g.stroke(&[(3.0, 13.0), (4.0, 10.0)], c);
|
||||
}
|
||||
Icon::Share => {
|
||||
@@ -645,18 +678,18 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.poly(&half, c);
|
||||
}
|
||||
Icon::Book => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(GREEN), None);
|
||||
g.rect(4.0, 4.0, 8.0, 8.0, Some(Color32::WHITE), None);
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 12.0)], GREEN);
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], GREEN);
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(green), None);
|
||||
g.rect(4.0, 4.0, 8.0, 8.0, Some(paper), None);
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 12.0)], green);
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], green);
|
||||
}
|
||||
Icon::Folder => {
|
||||
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW);
|
||||
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow);
|
||||
}
|
||||
Icon::Header | Icon::Footer => {
|
||||
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
|
||||
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
|
||||
g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None);
|
||||
g.rect(4.5, y, 7.0, 2.5, Some(blue), None);
|
||||
for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } {
|
||||
g.stroke(&[(5.5, yy), (10.5, yy)], light);
|
||||
}
|
||||
@@ -665,16 +698,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
if icon == Icon::PageCount {
|
||||
g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light));
|
||||
}
|
||||
g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.5, 3.0, 8.5, 12.0, Some(paper), Some(c));
|
||||
// A "#" mark.
|
||||
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE);
|
||||
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE);
|
||||
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE);
|
||||
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE);
|
||||
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], blue);
|
||||
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], blue);
|
||||
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], blue);
|
||||
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], blue);
|
||||
}
|
||||
Icon::Calendar => {
|
||||
g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None);
|
||||
g.rect(2.0, 3.0, 12.0, 11.0, Some(paper), Some(c));
|
||||
g.rect(2.0, 3.0, 12.0, 3.0, Some(blue), None);
|
||||
for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] {
|
||||
g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None);
|
||||
}
|
||||
@@ -682,13 +715,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
Icon::Clock => {
|
||||
let center = g.pt(8.0, 8.0);
|
||||
g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c));
|
||||
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE);
|
||||
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], blue);
|
||||
}
|
||||
Icon::FileName => {
|
||||
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE);
|
||||
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], paper);
|
||||
g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15);
|
||||
g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c);
|
||||
g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4);
|
||||
g.line(&[(5.0, 9.0), (11.0, 9.0)], blue, 1.4);
|
||||
g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light);
|
||||
}
|
||||
Icon::Back => {
|
||||
|
||||
@@ -103,6 +103,8 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
|
||||
L if (cmd || ctrl) && s => run("data.filter"),
|
||||
L if cmd && mac => run("home.alignLeft"),
|
||||
L if ctrl && mac => Shortcut::Dialog("defineName", json!({})),
|
||||
// Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands or collapses the formula bar.
|
||||
U if ctrl && s => run("view.expandFormulaBar"),
|
||||
U if ctrl && mac && !s => Shortcut::Edit,
|
||||
L if cmd => run("insert.table"),
|
||||
E if cmd && mac && !s => run("home.alignCenter"),
|
||||
@@ -157,6 +159,7 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
|
||||
F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})),
|
||||
F3 if cmd => Shortcut::Dialog("nameManager", json!({})),
|
||||
F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})),
|
||||
F3 if plain => Shortcut::Dialog("pasteName", json!({"shortcut": true})),
|
||||
F4 if cmd && !s => run("file.close"),
|
||||
F5 if plain && s => Shortcut::Dialog("find", json!({})),
|
||||
F5 if plain => Shortcut::Dialog("goTo", json!({})),
|
||||
|
||||
@@ -10,7 +10,12 @@ pub mod keytip_table;
|
||||
pub enum Action {
|
||||
Tab(&'static str),
|
||||
Command(&'static str),
|
||||
/// A command run with fixed params (a JSON object literal from keytips.json `params`), for
|
||||
/// menu items that share one command id, like the Borders presets (HBO bottom, HBN none).
|
||||
CommandWith(&'static str, &'static str),
|
||||
Dialog(&'static str),
|
||||
/// A dialog opened with fixed params, like Format Cells on its Border tab (HBM).
|
||||
DialogWith(&'static str, &'static str),
|
||||
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
|
||||
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
|
||||
Pending(&'static str),
|
||||
|
||||
@@ -50,8 +50,8 @@ pub fn is_group_level(seq: &str) -> bool {
|
||||
|
||||
fn matches_target(action: &Action, target: &str) -> bool {
|
||||
match action {
|
||||
Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target,
|
||||
Action::Dialog(name) => format!("dialog:{name}") == target,
|
||||
Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target,
|
||||
Action::Dialog(name) | Action::DialogWith(name, _) => format!("dialog:{name}") == target,
|
||||
Action::Tab(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,40 +38,40 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins)
|
||||
("HB", Entry::Continue), // Excel: Borders Menu; group level:
|
||||
("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders)
|
||||
("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD
|
||||
("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD
|
||||
("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD
|
||||
("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD
|
||||
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
|
||||
("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD
|
||||
("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD)
|
||||
("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD
|
||||
("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders)
|
||||
("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD
|
||||
("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD
|
||||
("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD
|
||||
("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD
|
||||
("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders)
|
||||
("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD
|
||||
("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD
|
||||
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
|
||||
("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD)
|
||||
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
|
||||
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
|
||||
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
|
||||
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
|
||||
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
|
||||
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
|
||||
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
|
||||
("HBA", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"all\"}"))), // Excel: All Borders; Borders menu item
|
||||
("HBB", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"doubleBottom\"}"))), // Excel: Bottom Double Border; Borders menu item
|
||||
("HBC", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topThickBottom\"}"))), // Excel: Top and Thick Bottom Border; Borders menu item
|
||||
("HBD", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topBottom\"}"))), // Excel: Top and Bottom Border; Borders menu item
|
||||
("HBE", Entry::Finish(Action::Pending("home.eraseBorder"))), // pending; Excel: Erase Border; pending: Excel arms an eraser pen; GridCraft has no border pen mode yet
|
||||
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
|
||||
("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item
|
||||
("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu
|
||||
("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
|
||||
("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
|
||||
("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
|
||||
("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item
|
||||
("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
|
||||
("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
|
||||
("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item
|
||||
("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item
|
||||
("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item
|
||||
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
|
||||
("HBY", Entry::Finish(Action::Pending("home.borderLineStyle"))), // pending; Excel: Line Style; pending: Line Style submenu
|
||||
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
|
||||
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
|
||||
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
|
||||
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
|
||||
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
|
||||
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
|
||||
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
|
||||
("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks
|
||||
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
|
||||
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
|
||||
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
|
||||
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
|
||||
("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting)
|
||||
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
|
||||
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
|
||||
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
|
||||
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
|
||||
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
|
||||
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
|
||||
("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands
|
||||
("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane
|
||||
("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace
|
||||
@@ -243,7 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
|
||||
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
|
||||
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
|
||||
("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands
|
||||
("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names
|
||||
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
|
||||
("MU", Entry::Continue), // Excel: AutoSum Menu; group level:
|
||||
("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user