mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 11:16:49 -06:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86aec12da | ||
|
|
bca67944b8 | ||
|
|
4995ecc66e | ||
|
|
17b9f6ff01 | ||
|
|
52fa4406c1 | ||
|
|
24e49d18ad | ||
|
|
850dc9c772 | ||
|
|
f17179ceeb | ||
|
|
47fadb8dcf | ||
|
|
87a649c763 | ||
|
|
77b37df655 | ||
|
|
ffa08d3a31 | ||
|
|
50c7ee3971 | ||
|
|
f65e9f6bed | ||
|
|
e1a12fa337 | ||
|
|
846d5b98ee | ||
|
|
6194f8241d | ||
|
|
1e50c72698 | ||
|
|
b58846a549 | ||
|
|
1713334579 | ||
|
|
cb1fb89549 | ||
|
|
75f7df9da6 | ||
|
|
8356b33c78 | ||
|
|
2741680144 | ||
|
|
924d36e247 | ||
|
|
ae88b40f58 | ||
|
|
0217dbdfd8 | ||
|
|
91a1d18c91 | ||
|
|
58aa0f1df2 | ||
|
|
2660973286 | ||
|
|
44cc84edf2 | ||
|
|
f1b06a9481 | ||
|
|
bb6f38d95d | ||
|
|
c44971fcd5 | ||
|
|
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 | ||
|
|
983d3d7e6a | ||
|
|
00b65d7e8c | ||
|
|
3557b9f6cd | ||
|
|
c22787e5bf | ||
|
|
450f457535 | ||
|
|
a0fa6c6e6e | ||
|
|
b0ab19701d | ||
|
|
d0fdf948db | ||
|
|
3b05abe05e | ||
|
|
09973e7831 | ||
|
|
672c102eb2 |
Generated
+16
-16
@@ -1505,7 +1505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1528,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1540,7 +1540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1551,7 +1551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1563,7 +1563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1595,7 +1595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1604,7 +1604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1612,7 +1612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1621,7 +1621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1633,18 +1633,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1666,7 +1666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1683,7 +1683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -4537,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.1"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
+10
@@ -135,6 +135,14 @@ 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) |
|
||||
@@ -181,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) |
|
||||
@@ -254,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,7 +315,7 @@ 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"))
|
||||
@@ -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),
|
||||
|
||||
@@ -99,3 +99,7 @@ real_name = "Tyrel Haveman"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
+132
-9
@@ -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
|
||||
}
|
||||
|
||||
@@ -1984,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,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)] {
|
||||
@@ -570,6 +596,24 @@ 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();
|
||||
@@ -1007,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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-14
@@ -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,
|
||||
@@ -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", "", ""];
|
||||
|
||||
@@ -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")] {
|
||||
|
||||
@@ -371,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")));
|
||||
}
|
||||
}
|
||||
@@ -400,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"] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+274
-16
@@ -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> {
|
||||
@@ -1462,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 {
|
||||
@@ -1786,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.
|
||||
|
||||
@@ -1034,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}`"));
|
||||
}
|
||||
@@ -1265,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, Workbook};
|
||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -55,6 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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),
|
||||
@@ -510,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);
|
||||
@@ -664,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}))
|
||||
}
|
||||
|
||||
@@ -877,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,
|
||||
|
||||
@@ -552,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",
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+174
-93
@@ -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 {
|
||||
@@ -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();
|
||||
|
||||
@@ -401,8 +401,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+145
-6
@@ -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 {
|
||||
|
||||
+126
-4
@@ -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();
|
||||
|
||||
@@ -199,6 +199,9 @@ pub struct DocState {
|
||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||
/// changed by a trust decision.
|
||||
pub trust: Trust,
|
||||
/// 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);
|
||||
@@ -285,6 +288,7 @@ impl DocState {
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
trust,
|
||||
go_to_recent: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -347,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 {
|
||||
@@ -361,6 +386,7 @@ impl Default for Prefs {
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
status_bar: StatusBarStats::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+39
-17
@@ -816,11 +816,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 +853,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 +978,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 +1345,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());
|
||||
}
|
||||
|
||||
@@ -941,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();
|
||||
@@ -2784,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",
|
||||
@@ -4604,4 +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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+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)]
|
||||
|
||||
@@ -264,15 +264,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,10 +319,32 @@ 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,6 +80,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 +156,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,
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
+146
-16
@@ -642,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`.
|
||||
@@ -786,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",
|
||||
@@ -824,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!({})),
|
||||
@@ -1264,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),
|
||||
@@ -1592,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;
|
||||
}
|
||||
@@ -1675,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;
|
||||
}
|
||||
@@ -2319,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:");
|
||||
@@ -2339,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 {
|
||||
@@ -2496,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"),
|
||||
@@ -2511,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"),
|
||||
@@ -3066,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;
|
||||
}
|
||||
@@ -3142,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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-16
@@ -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()] {
|
||||
@@ -3149,6 +3165,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;
|
||||
@@ -3156,7 +3175,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.
|
||||
@@ -3184,7 +3203,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.
|
||||
@@ -3353,6 +3373,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 == "-" {
|
||||
@@ -3409,10 +3435,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 =
|
||||
@@ -3421,7 +3485,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 };
|
||||
@@ -3464,9 +3529,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();
|
||||
@@ -3493,10 +3559,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 == "-" {
|
||||
@@ -3662,9 +3729,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!({})),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1666,11 +1666,10 @@
|
||||
},
|
||||
{
|
||||
"seq": "MSP",
|
||||
"kind": "command",
|
||||
"target": "formulas.pasteName",
|
||||
"kind": "dialog",
|
||||
"target": "pasteName",
|
||||
"excel": "Paste Names",
|
||||
"note": "reserved until the command lands",
|
||||
"pending": true
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"seq": "MT",
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod backstage;
|
||||
mod border_preview;
|
||||
mod cell_pictures;
|
||||
pub mod chartview;
|
||||
pub mod code_editor;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
@@ -632,6 +633,10 @@ impl SheetApp {
|
||||
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"view.expandFormulaBar" => {
|
||||
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
|
||||
Ok(json!({"on": self.ui.formula_bar_expanded}))
|
||||
}
|
||||
"view.darkMode" => {
|
||||
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
|
||||
self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current);
|
||||
@@ -748,6 +753,7 @@ impl SheetApp {
|
||||
});
|
||||
self.begin_edit(text, false);
|
||||
}
|
||||
UiRequest::InsertName(name) => dialogs::insert_name(self, &name),
|
||||
UiRequest::OpenUrl(u) => {
|
||||
if let Some(f) = &self.services.open_url {
|
||||
f(&u);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the
|
||||
//! row headers and one above the column headers, one lane per outline level, with a bracket per
|
||||
//! expanded group, a − / + button on its summary line (below / right of the detail) and the level
|
||||
//! buttons 1…n+1 in the corner. The bands exist only while the sheet has an outline.
|
||||
//! expanded group, a − / + button on its summary line (below / right of the detail, or above /
|
||||
//! left of it when the sheet's outline settings say so) and the level buttons 1…n+1 in the
|
||||
//! corner. The bands exist only while the sheet has an outline.
|
||||
//!
|
||||
//! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates,
|
||||
//! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands
|
||||
@@ -31,6 +32,12 @@ impl Axis {
|
||||
pub fn new(lines: &BTreeMap<u32, LineInfo>) -> Axis {
|
||||
Axis { levels: outline::max_level(lines), groups: outline::groups(lines) }
|
||||
}
|
||||
/// The outline of a sheet's rows (`rows`) or columns, with its summary lines where the
|
||||
/// sheet's outline settings put them.
|
||||
pub fn of(sh: &Sheet, rows: bool) -> Axis {
|
||||
let lines = if rows { &sh.rows } else { &sh.cols };
|
||||
Axis { levels: outline::max_level(lines), groups: outline::groups_with(lines, outline::summary_before(sh, rows)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%).
|
||||
@@ -197,7 +204,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
|
||||
for grp in &axis.groups {
|
||||
let across = dir.lane(step, grp.level);
|
||||
// The button on the summary line, if that line is on screen.
|
||||
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.summary() > grp.end);
|
||||
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.has_summary());
|
||||
if let Some(mid) = button {
|
||||
out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } });
|
||||
}
|
||||
@@ -207,6 +214,18 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
|
||||
// The bracket, over the part of the detail that is on screen.
|
||||
let first = dir.first_visible(grp.start, grp.end);
|
||||
let Some((first_line, (a0, _))) = first else { continue };
|
||||
if grp.summary_before {
|
||||
// From the button down (or across) to the last detail line, with the tick there.
|
||||
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
|
||||
let end = dir.start(grp.end) + dir.size(grp.end);
|
||||
let a1 = end.min(dir.hi);
|
||||
if a1 <= a0 {
|
||||
continue;
|
||||
}
|
||||
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
|
||||
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
|
||||
continue;
|
||||
}
|
||||
let a1 = match button {
|
||||
Some(mid) => mid - side / 2.0,
|
||||
None => dir.start(grp.summary()).min(dir.hi),
|
||||
@@ -381,6 +400,41 @@ mod tests {
|
||||
assert_eq!(outer.action.label(), "\u{2212}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summaries_above_and_left_take_the_toggles_and_the_bracket_ticks_move_to_the_end() {
|
||||
let mut sh = sheet();
|
||||
sh.summary_below = false;
|
||||
sh.summary_right = false;
|
||||
let g = geo(&sh);
|
||||
let l = layout(&g, &sh);
|
||||
let toggle = |rows: bool, level: u8| {
|
||||
l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: r, group } if r == rows && group.level == level)).unwrap()
|
||||
};
|
||||
// Rows 5:8: the outer − is on row 4 (index 3), the inner one (rows 6:7) on row 5.
|
||||
let outer = toggle(true, 1);
|
||||
assert!(g.cell_rect(&sh, CellRef::new(3, 0)).y_range().contains(outer.rect.center().y));
|
||||
assert!(g.cell_rect(&sh, CellRef::new(4, 0)).y_range().contains(toggle(true, 2).rect.center().y));
|
||||
// Column group C:E: its − is above column B.
|
||||
let col = toggle(false, 1);
|
||||
assert!(g.cell_rect(&sh, CellRef::new(0, 1)).x_range().contains(col.rect.center().x));
|
||||
// The bracket runs from the button to the end of row 8, with the tick there.
|
||||
assert_eq!(l.brackets.len(), 3);
|
||||
let b = l.brackets.iter().find(|b| (b.line[0].x - outer.rect.center().x).abs() < 0.01).unwrap();
|
||||
let row8 = g.cell_rect(&sh, CellRef::new(7, 0));
|
||||
assert!((b.line[0].y - outer.rect.bottom()).abs() < 0.01 && (b.line[1].y - (row8.bottom() - 0.5)).abs() < 1.0);
|
||||
assert!(b.tick.is_some_and(|t| (t[0].y - b.line[1].y).abs() < 0.01 && t[1].x > t[0].x));
|
||||
// A group on the first row has no row above it: no toggle, still a bracket.
|
||||
let mut sh = Sheet::new("S");
|
||||
sh.summary_below = false;
|
||||
for r in 0..=2 {
|
||||
sh.rows.entry(r).or_default().outline = 1;
|
||||
}
|
||||
let g = geo(&sh);
|
||||
let l = layout(&g, &sh);
|
||||
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { .. })));
|
||||
assert_eq!(l.brackets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_groups_show_plus_and_no_bracket() {
|
||||
let mut sh = sheet();
|
||||
|
||||
@@ -57,12 +57,13 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if list.is_empty() {
|
||||
ui.label(egui::RichText::new("No comments on this sheet yet. Select a cell and choose New Comment.").italics());
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
for (cell, c) in list {
|
||||
egui::Frame::NONE
|
||||
.fill(if c.resolved { Color32::from_gray(240) } else { Color32::WHITE })
|
||||
.fill(if c.resolved { t.window } else { t.menu_bg })
|
||||
.corner_radius(6.0)
|
||||
.inner_margin(8.0)
|
||||
.stroke(egui::Stroke::new(1.0, Color32::from_gray(215)))
|
||||
.stroke(egui::Stroke::new(1.0, t.separator))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -70,14 +71,14 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let _ = app.run("selection.set", json!({"cell": cell}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
ui.label(egui::RichText::new(&c.author).color(Color32::from_gray(90)));
|
||||
ui.label(egui::RichText::new(&c.author).color(t.text_dim));
|
||||
if c.resolved {
|
||||
ui.label(egui::RichText::new("Resolved").small().color(Color32::from_rgb(0x10, 0x7C, 0x41)));
|
||||
ui.label(egui::RichText::new("Resolved").small().color(t.accent));
|
||||
}
|
||||
});
|
||||
ui.label(&c.text);
|
||||
for (a, txt) in &c.replies {
|
||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60)));
|
||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(t.text));
|
||||
}
|
||||
let key = egui::Id::new(("reply", &cell));
|
||||
let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default();
|
||||
@@ -239,7 +240,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, col);
|
||||
ui.label(&name);
|
||||
ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110)));
|
||||
ui.label(egui::RichText::new(&s.values).small().color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||||
ui.menu_button("Fill…", |ui| {
|
||||
if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") {
|
||||
app.run_or_alert(
|
||||
|
||||
@@ -182,7 +182,8 @@ fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str {
|
||||
/// Section rectangles (left, centre, right) of a page's header or footer on screen.
|
||||
fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] {
|
||||
let (px, py, w, h) = pg.page_rect(i, j);
|
||||
let [l, r, t, b, ..] = pg.margins;
|
||||
let [_, _, t, b, ..] = pg.margins;
|
||||
let [l, r] = pg.hf_sides;
|
||||
let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) };
|
||||
let inner = (w - l - r).max(3.0);
|
||||
let third = inner / 3.0;
|
||||
@@ -195,8 +196,8 @@ fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool)
|
||||
out
|
||||
}
|
||||
|
||||
fn hf_font(geo: &Geo, size_pt: Option<f32>, bold: bool) -> FontId {
|
||||
let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z;
|
||||
fn hf_font(geo: &Geo, pg: &PageGrid, size_pt: Option<f32>, bold: bool) -> FontId {
|
||||
let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * pg.hf_text_k * geo.z;
|
||||
let px = px.clamp(4.0, 120.0);
|
||||
if bold { theme::ui_bold(px) } else { theme::ui_font(px) }
|
||||
}
|
||||
@@ -235,7 +236,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter,
|
||||
if spec.is_empty() {
|
||||
if hovered {
|
||||
let label = if footer { "Add footer" } else { "Add header" };
|
||||
p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120));
|
||||
p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, &pg, Some(10.0), false), Color32::from_gray(120));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -255,7 +256,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter,
|
||||
if sec.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let font = hf_font(geo, sec.size, sec.bold);
|
||||
let font = hf_font(geo, &pg, sec.size, sec.bold);
|
||||
let r = rects[k];
|
||||
let (anchor, x) = match k {
|
||||
0 => (Align2::LEFT_TOP, r.left()),
|
||||
@@ -326,8 +327,9 @@ pub fn start_editing(app: &mut SheetApp) {
|
||||
app.grid.hf_start = true;
|
||||
}
|
||||
|
||||
/// Turns Different First Page / Different Odd & Even Pages on or off, keeping the edit on the
|
||||
/// same page (which may now show another header).
|
||||
/// Turns a Header & Footer option (Different First Page, Different Odd & Even Pages, Scale with
|
||||
/// Document, Align with Page Margins) on or off, keeping the edit on the same page (which may
|
||||
/// now show another header).
|
||||
fn set_option(app: &mut SheetApp, key: &str, on: bool) {
|
||||
let Some(e) = app.grid.hf_edit.as_ref() else { return };
|
||||
let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section });
|
||||
@@ -446,7 +448,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
|
||||
let rects = section_rects(geo, pg, e.page, e.footer);
|
||||
let p = ui.painter().with_clip_rect(geo.cells);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = hf_font(geo, Some(10.0), false);
|
||||
let font = hf_font(geo, pg, Some(10.0), false);
|
||||
let mut switch_to = None;
|
||||
for (k, r) in rects.iter().enumerate() {
|
||||
if !r.intersects(geo.cells) {
|
||||
@@ -893,11 +895,10 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
go_to(app, true);
|
||||
}
|
||||
sep(ui);
|
||||
let (first, odd_even) = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet)).map(|s| (s.print.different_first, s.print.different_odd_even)))
|
||||
.unwrap_or_default();
|
||||
let (first, odd_even, scale_doc, align) =
|
||||
app.session.active().and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet))).map_or((false, false, true, true), |s| {
|
||||
(s.print.different_first, s.print.different_odd_even, s.print.hf_scale_with_doc, s.print.hf_align_with_margins)
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
let (mut f, mut o) = (first, odd_even);
|
||||
if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() {
|
||||
@@ -907,6 +908,15 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
set_option(app, "differentOddEven", o);
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
let (mut sd, mut am) = (scale_doc, align);
|
||||
if ui.checkbox(&mut sd, tr(app, "Scale with Document")).changed() {
|
||||
set_option(app, "scaleWithDoc", sd);
|
||||
}
|
||||
if ui.checkbox(&mut am, tr(app, "Align with Page Margins")).changed() {
|
||||
set_option(app, "alignWithMargins", am);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn sep(ui: &mut egui::Ui) {
|
||||
|
||||
+211
-60
@@ -7,8 +7,8 @@ use serde_json::json;
|
||||
use crate::SheetApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::keytips::keytip_table;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button};
|
||||
use crate::theme::{self, Cat, Tokens};
|
||||
use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button};
|
||||
|
||||
pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT });
|
||||
ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside);
|
||||
let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) };
|
||||
ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim });
|
||||
ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } else { t.text_dim });
|
||||
if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() {
|
||||
app.toggle_autosave();
|
||||
}
|
||||
@@ -279,36 +279,122 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if collapsed {
|
||||
return;
|
||||
}
|
||||
// Ribbon card.
|
||||
let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 88.0));
|
||||
// Ribbon card. The row above it can be wider than a narrow window, so the card is kept
|
||||
// within the window (with the margin it has on the left), or its right scroll arrow
|
||||
// would be drawn off screen.
|
||||
let left = ui.cursor().min.x;
|
||||
let screen = ui.ctx().content_rect();
|
||||
let fits = (screen.right() - left - (left - screen.left()).max(0.0)).max(0.0);
|
||||
let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width().min(fits), 88.0));
|
||||
ui.painter().rect_filled(card, 10.0, t.ribbon);
|
||||
ui.painter().rect_stroke(card, 10.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||
let inner = card.shrink2(vec2(10.0, 6.0));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
|
||||
egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.ribbon_tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Page Layout" => page_layout(app, ui),
|
||||
"Formulas" => formulas(app, ui),
|
||||
"Data" => data(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Automate" => automate(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
// One scroll state per tab: a tab opened after scrolling another starts at its
|
||||
// left edge, and a tab keeps its own offset while others are used.
|
||||
let scroll = egui::ScrollArea::horizontal()
|
||||
.id_salt(("ribbon_scroll", app.ui.ribbon_tab.as_str()))
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.ribbon_tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Page Layout" => page_layout(app, ui),
|
||||
"Formulas" => formulas(app, ui),
|
||||
"Data" => data(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Automate" => automate(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
scroll_arrows(ui, &t, scroll);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// How much of the active tab's groups the ribbon card shows, recorded each frame for the scroll
|
||||
/// arrows and for tests.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RibbonOverflow {
|
||||
/// Width of all the tab's groups.
|
||||
pub content: f32,
|
||||
/// Width of the card's content area.
|
||||
pub visible: f32,
|
||||
/// How far the groups are scrolled.
|
||||
pub offset: f32,
|
||||
/// Height of the tallest group (a column of small buttons) and of the content area.
|
||||
pub content_height: f32,
|
||||
pub visible_height: f32,
|
||||
}
|
||||
|
||||
impl RibbonOverflow {
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("ribbon_overflow")
|
||||
}
|
||||
|
||||
/// Last frame's overflow.
|
||||
pub fn get(ctx: &egui::Context) -> RibbonOverflow {
|
||||
ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the groups are wider than the card.
|
||||
pub fn overflows(&self) -> bool {
|
||||
self.content > self.visible + 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// When the tab's groups are wider than the card, arrows at its edges scroll them (the mouse wheel
|
||||
/// over the card still does too).
|
||||
fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollAreaOutput<()>) {
|
||||
let inner = scroll.inner_rect;
|
||||
let max = (scroll.content_size.x - inner.width()).max(0.0);
|
||||
let overflow = RibbonOverflow {
|
||||
content: scroll.content_size.x,
|
||||
visible: inner.width(),
|
||||
offset: scroll.state.offset.x,
|
||||
content_height: scroll.content_size.y,
|
||||
visible_height: inner.height(),
|
||||
};
|
||||
ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::id(), overflow));
|
||||
if !overflow.overflows() {
|
||||
return;
|
||||
}
|
||||
let step = (inner.width() * 0.6).max(60.0);
|
||||
let mut target = None;
|
||||
for (left, show) in [(true, overflow.offset > 0.5), (false, overflow.offset < max - 0.5)] {
|
||||
if !show {
|
||||
continue;
|
||||
}
|
||||
let r = if left {
|
||||
Rect::from_min_size(inner.left_top(), vec2(18.0, inner.height()))
|
||||
} else {
|
||||
Rect::from_min_size(pos2(inner.right() - 18.0, inner.top()), vec2(18.0, inner.height()))
|
||||
};
|
||||
let resp = ui.interact(r, ui.id().with(("ribbon_scroll_arrow", left)), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.ribbon });
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if left { Icon::Left } else { Icon::Right }, t.text);
|
||||
let name = if left { "Scroll ribbon left" } else { "Scroll ribbon right" };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name));
|
||||
if resp.on_hover_text(crate::i18n::current(ui.ctx()).tr(name)).clicked() {
|
||||
target = Some(if left { overflow.offset - step } else { overflow.offset + step });
|
||||
}
|
||||
}
|
||||
if let Some(x) = target {
|
||||
scroll.state.offset.x = x.clamp(0.0, max);
|
||||
scroll.state.store(ui.ctx(), scroll.id);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like
|
||||
/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes.
|
||||
fn file_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
@@ -382,6 +468,23 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
|
||||
app.run_or_alert(id, params);
|
||||
}
|
||||
|
||||
/// How many cells Formulas › Error Checking › Circular References lists.
|
||||
const CIRCULAR_MENU_LIMIT: usize = 100;
|
||||
|
||||
/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order
|
||||
/// the calculation found them, as (sheet index, cell, "Sheet!A1").
|
||||
fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> {
|
||||
let Some(d) = app.session.active() else { return Vec::new() };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
d.calc
|
||||
.circular
|
||||
.iter()
|
||||
.filter(|k| seen.insert(**k))
|
||||
.filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", gridcraft_engine::formula::quote_sheet(&sh.name), c.a1()))))
|
||||
.take(CIRCULAR_MENU_LIMIT)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
|
||||
fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
|
||||
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
|
||||
@@ -1064,17 +1167,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if s {
|
||||
act(app, "formulas.autoSum", json!({}));
|
||||
}
|
||||
egui::Popup::menu(&sa).show(|ui| {
|
||||
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
|
||||
if ui.button(f).clicked() {
|
||||
act(app, "formulas.autoSum", json!({"function": f}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("More Functions…").clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
});
|
||||
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
|
||||
});
|
||||
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
|
||||
for sequence in keytip_table::group_extension(
|
||||
@@ -1167,6 +1260,20 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
|
||||
/// Function dialog.
|
||||
fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
|
||||
if ui.button(f).clicked() {
|
||||
act(app, "formulas.autoSum", json!({"function": f}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("More Functions…").clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||
let name = st.font.name.clone();
|
||||
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
|
||||
@@ -1389,7 +1496,7 @@ fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click());
|
||||
paint_table_swatch(ui, r, &wb, &name);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.on_hover_text(&name).clicked() {
|
||||
act(app, cmd, json!({"style": name}));
|
||||
@@ -1407,7 +1514,8 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook,
|
||||
let lk = gridcraft_engine::tables::look(style);
|
||||
let res = |c: Option<gridcraft_engine::model::Color>| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
p.rect_filled(r, 0.0, t.grid_bg);
|
||||
let rows = 5;
|
||||
let rh = r.height() / rows as f32;
|
||||
for i in 0..rows {
|
||||
@@ -1427,14 +1535,15 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook,
|
||||
}
|
||||
for k in 0..3 {
|
||||
let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0;
|
||||
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(Color32::BLACK) } else { Color32::from_gray(120) };
|
||||
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border };
|
||||
p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col));
|
||||
}
|
||||
}
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
}
|
||||
|
||||
fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(520.0);
|
||||
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
|
||||
let groups: &[(&str, &[&str])] = &[
|
||||
@@ -1484,17 +1593,17 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
.resolve(&wb.theme)
|
||||
.filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None)
|
||||
.map(theme::color32)
|
||||
.unwrap_or(Color32::WHITE);
|
||||
.unwrap_or(t.grid_bg);
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
if !st.borders.bottom.is_none() {
|
||||
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
|
||||
ui.painter().line_segment(
|
||||
[r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)],
|
||||
Stroke::new(st.borders.bottom.style.width(), c),
|
||||
);
|
||||
}
|
||||
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
|
||||
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
@@ -1504,7 +1613,7 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
col,
|
||||
);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
act(app, "home.cellStyle", json!({"name": n}));
|
||||
@@ -1599,6 +1708,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
("100% Stacked Column", "column", "stacked100"),
|
||||
("Clustered Bar", "bar", ""),
|
||||
("Stacked Bar", "bar", "stacked"),
|
||||
("100% Stacked Bar", "bar", "stacked100"),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1611,6 +1721,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
("Stacked Line", "line", "stacked"),
|
||||
("Area", "area", ""),
|
||||
("Stacked Area", "area", "stacked"),
|
||||
("100% Stacked Area", "area", "stacked100"),
|
||||
],
|
||||
),
|
||||
(Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]),
|
||||
@@ -1921,11 +2032,12 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
let s = big_button(ui, Icon::Sum, "AutoSum", "AutoSum", true);
|
||||
control_keytip(app, ui, &s, "formulas.autoSum");
|
||||
let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
|
||||
control_keytip(app, ui, &sa, "formulas.autoSum");
|
||||
if s.clicked() {
|
||||
act(app, "formulas.autoSum", json!({}));
|
||||
}
|
||||
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
|
||||
for (label, cat, cid) in [
|
||||
("Financial", "Financial", "formulas.financial"),
|
||||
("Logical", "Logical", "formulas.logical"),
|
||||
@@ -1971,14 +2083,36 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
app.open_dialog("defineName", json!({}));
|
||||
}
|
||||
let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true);
|
||||
anchor_keytip(app, ui, &use_in, &["formulas.pasteName"]);
|
||||
anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]);
|
||||
egui::Popup::menu(&use_in).show(|ui| {
|
||||
let names: Vec<String> = app.session.active().map(|d| d.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||||
for n in names {
|
||||
if ui.button(&n).clicked() {
|
||||
let cur = app.editor.as_ref().map(|e| e.text.clone()).unwrap_or_else(|| "=".into());
|
||||
app.begin_edit(Some(format!("{cur}{n}")), false);
|
||||
// The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret.
|
||||
// Listed when the menu opens, and again only after the workbook changes.
|
||||
let cache = egui::Id::new("gridcraft.use_in_formula_names");
|
||||
let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet));
|
||||
let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc<Vec<String>>)>(cache)).filter(|(k, _)| *k == key);
|
||||
let names = match cached {
|
||||
Some((_, names)) => names,
|
||||
None => {
|
||||
let names: std::sync::Arc<Vec<String>> = std::sync::Arc::new(
|
||||
app.session
|
||||
.active()
|
||||
.map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
ui.ctx().data_mut(|m| m.insert_temp(cache, (key, names.clone())));
|
||||
names
|
||||
}
|
||||
};
|
||||
for n in names.iter() {
|
||||
if ui.button(n).clicked() {
|
||||
app.run_or_alert("formulas.useInFormula", json!({"name": n}));
|
||||
}
|
||||
}
|
||||
if !names.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Paste Names…")).clicked() {
|
||||
app.open_dialog("pasteName", json!({}));
|
||||
}
|
||||
});
|
||||
let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false);
|
||||
@@ -2009,11 +2143,30 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
act(app, "formulas.showFormulas", json!({}));
|
||||
}
|
||||
let __k = small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false);
|
||||
control_keytip(app, ui, &__k, "dialog:errorChecking");
|
||||
if __k.clicked() {
|
||||
let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking");
|
||||
control_keytip(app, ui, &ec, "dialog:errorChecking");
|
||||
if ec.clicked() {
|
||||
app.open_dialog("errorChecking", json!({}));
|
||||
}
|
||||
egui::Popup::menu(&ec_arrow).show(|ui| {
|
||||
let lang = app.ui.language;
|
||||
if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() {
|
||||
app.open_dialog("errorChecking", json!({}));
|
||||
}
|
||||
let circular = circular_references(app);
|
||||
ui.add_enabled_ui(!circular.is_empty(), |ui| {
|
||||
ui.menu_button(lang.tr("Circular References"), |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| {
|
||||
for (sheet, cell, label) in circular {
|
||||
if ui.button(label).clicked() {
|
||||
act(app, "selection.set", json!({"range": cell, "sheet": sheet}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false);
|
||||
control_keytip(app, ui, &__k, "dialog:evaluateFormula");
|
||||
if __k.clicked() {
|
||||
@@ -2176,6 +2329,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
|
||||
act(app, "data.hideDetail", json!({}));
|
||||
}
|
||||
if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() {
|
||||
app.open_dialog("outlineSettings", json!({}));
|
||||
}
|
||||
});
|
||||
sep(ui);
|
||||
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
|
||||
@@ -2704,11 +2860,6 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: &Tokens, _: Icon) {
|
||||
let _ = icons::GREEN;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use super::*;
|
||||
|
||||
+178
-38
@@ -55,6 +55,46 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option<serde_json::Value> {
|
||||
Some(json!({"sheet": i, "to": to}))
|
||||
}
|
||||
|
||||
/// The New sheet (+) button and the gap before it.
|
||||
const NEW_SHEET_WIDTH: f32 = 32.0;
|
||||
/// The handle between the tabs and the horizontal scrollbar.
|
||||
const SPLIT_WIDTH: f32 = 8.0;
|
||||
/// The narrowest the scrollbar track gets when the handle is dragged to the right.
|
||||
const MIN_TRACK: f32 = 100.0;
|
||||
|
||||
/// The share of the tab bar (`bar`: its left edge and width) the tabs and New sheet take with
|
||||
/// the handle dragged to `x`: New sheet stays shown, and the track ending at `right` keeps
|
||||
/// [`MIN_TRACK`].
|
||||
fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 {
|
||||
let (left, width) = bar;
|
||||
if !width.is_finite() || width <= 0.0 || !x.is_finite() {
|
||||
return 0.6;
|
||||
}
|
||||
let min = NEW_SHEET_WIDTH / width;
|
||||
let max = ((right - SPLIT_WIDTH - MIN_TRACK - left) / width).max(min);
|
||||
((x - left) / width).clamp(min, max)
|
||||
}
|
||||
|
||||
/// The handle at the left edge of the horizontal scrollbar: dragging it gives the sheet tabs more
|
||||
/// or less of the bar, as in Excel, for the workbook `uid`.
|
||||
fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) {
|
||||
app.grid.tab_split = Some(split);
|
||||
let resp = ui.interact(split, ui.id().with("tab_split"), Sense::drag());
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
app.grid.tab_shares.insert(uid, dragged_share(p.x - SPLIT_WIDTH / 2.0, bar, right));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let color = if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled };
|
||||
for dy in [-4.0, 0.0, 4.0] {
|
||||
ui.painter().circle_filled(pos2(split.center().x, split.center().y + dy), 1.0, color);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
|
||||
@@ -81,6 +121,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let Some(d) = app.session.active() else { return };
|
||||
let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width());
|
||||
let uid = d.uid;
|
||||
// The track ends where the grid does, so it stops under the vertical scrollbar. A share
|
||||
// dragged in a wider window is held to the same limits as a drag at this width.
|
||||
let bar_right = app.grid.rect.map_or(bar_left + bar_width, |r| r.right()).min(bar_left + bar_width);
|
||||
let share = app.grid.tab_shares.get(&uid).map(|&share| dragged_share(bar_left + bar_width * share, (bar_left, bar_width), bar_right));
|
||||
let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> =
|
||||
d.wb.sheets
|
||||
.iter()
|
||||
@@ -91,7 +137,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
// Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the
|
||||
// tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip
|
||||
// that needs less shrinks to its tabs, so New sheet (+) sits right after the last one.
|
||||
let tabs_width = (ui.available_width() * 0.6).max(100.0);
|
||||
// After the handle at the scrollbar's left edge is dragged, the tabs and New sheet
|
||||
// take the share it was dragged to.
|
||||
let tabs_width = match share {
|
||||
Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0),
|
||||
None => (bar_width * 0.6).max(100.0),
|
||||
};
|
||||
// Reveal the selected sheet after navigation or layout changes, but
|
||||
// allow browsing other tabs without snapping back on every frame.
|
||||
let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
|
||||
@@ -116,6 +167,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("worksheet_tabs_horizontal")
|
||||
.auto_shrink([true, false])
|
||||
.min_scrolled_width(0.0)
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
if wheel_scroll.abs() > 0.0 {
|
||||
@@ -251,7 +303,13 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
// The track ends where the grid does, so it stops under the vertical scrollbar.
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right());
|
||||
let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
|
||||
// The handle sits at the track's left edge. With a dragged share, the room the tabs
|
||||
// don't fill stays empty before it.
|
||||
let split_left =
|
||||
share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left()));
|
||||
let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom()));
|
||||
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
|
||||
let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
|
||||
crate::grid::horizontal_scrollbar(app, ui, track, &t);
|
||||
});
|
||||
});
|
||||
@@ -262,6 +320,13 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
// A right-click anywhere on the status bar opens the statistics menu, the text included, so its labels are not
|
||||
// selectable. The zoom and view controls sense clicks and handle their own.
|
||||
let background = ui.interact(ui.max_rect(), egui::Id::new("gridcraft.status_bar"), Sense::click());
|
||||
background.context_menu(|ui| status_bar_menu(app, ui));
|
||||
let text = |ui: &mut Ui, rich: egui::RichText| {
|
||||
ui.add(egui::Label::new(rich).selectable(false));
|
||||
};
|
||||
ui.horizontal_centered(|ui| {
|
||||
let mode = match app.session.mode {
|
||||
gridcraft_engine::Mode::Ready => "Ready",
|
||||
@@ -269,25 +334,29 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
gridcraft_engine::Mode::Edit => "Edit",
|
||||
gridcraft_engine::Mode::Point => "Point",
|
||||
};
|
||||
ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
if let Some(d) = app.session.active() {
|
||||
if d.wb.vba_project.is_some() {
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
|
||||
text(ui, egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
|
||||
}
|
||||
if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual {
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
text(ui, egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
}
|
||||
if !d.calc.circular.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
let (si, c) = d.calc.circular[0];
|
||||
let name = d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
ui.label(egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger));
|
||||
// Only a loop on the active sheet is named, so the address needs no sheet.
|
||||
let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet) {
|
||||
Some((_, c)) => format!("Circular References: {}", c.a1()),
|
||||
None => "Circular References".to_owned(),
|
||||
};
|
||||
text(ui, egui::RichText::new(label).font(theme::ui_font(12.5)).color(t.danger));
|
||||
}
|
||||
if app.session.clipboard.is_some() && app.editor.is_none() {
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
text(
|
||||
ui,
|
||||
egui::RichText::new(app.ui.language.tr("Select destination and press Enter or choose Paste"))
|
||||
.font(theme::ui_font(12.5))
|
||||
.color(t.text_dim),
|
||||
@@ -336,34 +405,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// Average / Count / Sum.
|
||||
if let Some(d) = app.session.active()
|
||||
&& let Some(sh) = d.wb.active()
|
||||
&& !d.selection.is_single_cell()
|
||||
{
|
||||
let st = gridcraft_engine::display::stats(sh, &d.selection.ranges);
|
||||
let fmt = |v: f64| {
|
||||
let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string();
|
||||
let code = if code == "General" { "#,##0.##########".to_string() } else { code };
|
||||
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb)
|
||||
.text
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
if st.numeric > 0 {
|
||||
parts.push(format!("Sum: {}", fmt(st.sum)));
|
||||
}
|
||||
if st.count > 0 {
|
||||
parts.push(format!("Count: {}", st.count));
|
||||
}
|
||||
if let Some(a) = st.average {
|
||||
parts.push(format!("Average: {}", fmt(a)));
|
||||
}
|
||||
for p in parts {
|
||||
ui.label(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
// The layout runs right to left, so the last statistic is drawn first.
|
||||
for p in selection_statistics(app).into_iter().rev() {
|
||||
text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -371,9 +416,69 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The statistics the status bar shows for a selection of more than one cell, left to right: Average, Count,
|
||||
/// Numerical Count, Minimum, Maximum, Sum. Each one shows when it is turned on (`view.statusBar`)
|
||||
/// and the selection has a value for it.
|
||||
pub(crate) fn selection_statistics(app: &SheetApp) -> Vec<String> {
|
||||
let Some(d) = app.session.active() else { return vec![] };
|
||||
let Some(sh) = d.wb.active() else { return vec![] };
|
||||
if d.selection.is_single_cell() {
|
||||
return vec![];
|
||||
}
|
||||
let on = app.session.prefs.status_bar;
|
||||
let st = gridcraft_engine::display::stats(sh, &d.selection.ranges);
|
||||
let fmt = |v: f64| {
|
||||
let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string();
|
||||
let code = if code == "General" { "#,##0.##########".to_string() } else { code };
|
||||
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb).text.trim_end_matches('.').to_string()
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
if on.average
|
||||
&& let Some(a) = st.average
|
||||
{
|
||||
parts.push(format!("Average: {}", fmt(a)));
|
||||
}
|
||||
if on.count && st.count > 0 {
|
||||
parts.push(format!("Count: {}", st.count));
|
||||
}
|
||||
if on.numerical_count && st.count > 0 {
|
||||
parts.push(format!("Numerical Count: {}", st.numeric));
|
||||
}
|
||||
if on.minimum && st.numeric > 0 {
|
||||
parts.push(format!("Minimum: {}", fmt(st.min)));
|
||||
}
|
||||
if on.maximum && st.numeric > 0 {
|
||||
parts.push(format!("Maximum: {}", fmt(st.max)));
|
||||
}
|
||||
if on.sum && st.numeric > 0 {
|
||||
parts.push(format!("Sum: {}", fmt(st.sum)));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// The status bar's context menu: a check item for each statistic, each one running `view.statusBar`.
|
||||
fn status_bar_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.set_min_width(180.0);
|
||||
let st = app.session.prefs.status_bar;
|
||||
for (key, label, mut on) in [
|
||||
("average", "Average", st.average),
|
||||
("count", "Count", st.count),
|
||||
("numericalCount", "Numerical Count", st.numerical_count),
|
||||
("minimum", "Minimum", st.minimum),
|
||||
("maximum", "Maximum", st.maximum),
|
||||
("sum", "Sum", st.sum),
|
||||
] {
|
||||
if ui.checkbox(&mut on, app.ui.language.tr(label)).changed() {
|
||||
app.run_or_alert("view.statusBar", json!({ key: on }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop};
|
||||
use super::{
|
||||
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop,
|
||||
};
|
||||
use crate::SheetApp;
|
||||
use egui::Modifiers;
|
||||
use gridcraft_engine::model::{Sheet, Visibility};
|
||||
@@ -396,6 +501,30 @@ mod tests {
|
||||
app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_shows_the_statistics_that_are_turned_on_in_order() {
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "x"), ("A4", "9")] {
|
||||
app.run("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
app.run("selection.set", json!({"range": "A1:A5"})).unwrap();
|
||||
assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Sum: 12"]);
|
||||
app.run("view.statusBar", json!({"numericalCount": true, "minimum": true, "maximum": true})).unwrap();
|
||||
assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Numerical Count: 3", "Minimum: -1", "Maximum: 9", "Sum: 12"]);
|
||||
app.run("view.statusBar", json!({"average": false, "count": false, "sum": false})).unwrap();
|
||||
assert_eq!(super::selection_statistics(&app), ["Numerical Count: 3", "Minimum: -1", "Maximum: 9"]);
|
||||
// Text only: Count and Numerical Count, nothing that needs a number.
|
||||
app.run("view.statusBar", json!({"average": true, "count": true, "sum": true})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "B1", "input": "a"})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "B2", "input": "b"})).unwrap();
|
||||
app.run("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
assert_eq!(super::selection_statistics(&app), ["Count: 2", "Numerical Count: 0"]);
|
||||
app.run("selection.set", json!({"range": "A1"})).unwrap();
|
||||
assert!(super::selection_statistics(&app).is_empty(), "one cell shows no statistics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() {
|
||||
// egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS.
|
||||
@@ -459,4 +588,15 @@ mod tests {
|
||||
let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap();
|
||||
assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_share_keeps_new_sheet_and_the_scrollbar_shown() {
|
||||
// A 1,000 px bar from x = 100 whose track ends at x = 1,100.
|
||||
let bar = (100.0, 1000.0);
|
||||
assert_eq!(dragged_share(600.0, bar, 1100.0), 0.5);
|
||||
assert_eq!(dragged_share(0.0, bar, 1100.0), NEW_SHEET_WIDTH / 1000.0, "New sheet stays shown");
|
||||
assert_eq!(dragged_share(1100.0, bar, 1100.0), (1000.0 - SPLIT_WIDTH - MIN_TRACK) / 1000.0, "the track keeps its minimum");
|
||||
assert_eq!(dragged_share(f32::NAN, bar, 1100.0), 0.6);
|
||||
assert_eq!(dragged_share(600.0, (100.0, 0.0), 1100.0), 0.6, "an empty bar has the default share");
|
||||
}
|
||||
}
|
||||
|
||||
+222
-3
@@ -4,6 +4,11 @@
|
||||
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
|
||||
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
|
||||
//! fonts as the last resort.
|
||||
//!
|
||||
//! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours
|
||||
//! drawn on the sheet keep their light value in both themes, because the sheet stays white. The
|
||||
//! foreground/background pairs are listed in `contrast_tests`, which holds them to WCAG 2 (4.5:1
|
||||
//! for text, 3:1 for glyphs) in both themes: a new token goes there too.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -54,8 +59,74 @@ pub struct Tokens {
|
||||
pub notice_warn_border: Color32,
|
||||
pub notice_info_bg: Color32,
|
||||
pub notice_info_border: Color32,
|
||||
/// Text and glyphs drawn on an `accent` fill (primary buttons, switched-on toggles).
|
||||
pub on_accent: Color32,
|
||||
/// The category palette, indexed by [`Cat`]: the coloured parts of icons, gallery highlights.
|
||||
pub cat: [Color32; 8],
|
||||
/// The page colour inside icons (documents, cells, clipboards).
|
||||
pub paper: Color32,
|
||||
/// Reference colours for formulas edited in the window-coloured formula bar.
|
||||
pub ref_colors: [Color32; 8],
|
||||
/// Reference colours on the sheet (outlines and the in-cell editor). The sheet stays white.
|
||||
pub sheet_ref_colors: [Color32; 8],
|
||||
/// Code colours in the code editor (and the window-coloured fields it sits in).
|
||||
pub syntax: Syntax,
|
||||
/// The message box warning disc and its glyph.
|
||||
pub warning: Color32,
|
||||
pub on_warning: Color32,
|
||||
/// Controls drawn on the sheet (filter and validation buttons, object handles). They follow
|
||||
/// the sheet, so they are light in both themes.
|
||||
pub sheet_control_bg: Color32,
|
||||
pub sheet_control_border: Color32,
|
||||
pub sheet_control_text: Color32,
|
||||
/// A placeholder fill on the sheet (a picture that has not loaded).
|
||||
pub sheet_track: Color32,
|
||||
}
|
||||
|
||||
/// The eight category colours, in the order of [`Tokens::cat`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Cat {
|
||||
Blue,
|
||||
Green,
|
||||
Orange,
|
||||
Red,
|
||||
Purple,
|
||||
Teal,
|
||||
Amber,
|
||||
Slate,
|
||||
}
|
||||
|
||||
impl Cat {
|
||||
pub const ALL: [Cat; 8] = [Cat::Blue, Cat::Green, Cat::Orange, Cat::Red, Cat::Purple, Cat::Teal, Cat::Amber, Cat::Slate];
|
||||
}
|
||||
|
||||
const fn rgb(hex: u32) -> Color32 {
|
||||
Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
|
||||
}
|
||||
|
||||
/// Reference colours on the white sheet, the same in both themes (the sheet does not follow the
|
||||
/// theme). The light theme's formula bar is white too, so it uses them as well.
|
||||
const SHEET_REF_COLORS: [Color32; 8] =
|
||||
[rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)];
|
||||
/// Code colours for one background; plain text uses the surface's own text colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Syntax {
|
||||
pub keyword: Color32,
|
||||
pub string: Color32,
|
||||
pub number: Color32,
|
||||
pub comment: Color32,
|
||||
pub builtin: Color32,
|
||||
}
|
||||
|
||||
const LIGHT_SYNTAX: Syntax =
|
||||
Syntax { keyword: rgb(0x0B57B3), string: rgb(0xA3361B), number: rgb(0x157A5A), comment: rgb(0x6B7280), builtin: rgb(0x7B3FB5) };
|
||||
const DARK_SYNTAX: Syntax =
|
||||
Syntax { keyword: rgb(0x6FAEFF), string: rgb(0xF09A7A), number: rgb(0x7FD1A8), comment: rgb(0x9AA3AE), builtin: rgb(0xC69BF2) };
|
||||
const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3);
|
||||
const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A);
|
||||
const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F);
|
||||
const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
|
||||
|
||||
impl Tokens {
|
||||
pub fn light() -> Tokens {
|
||||
Tokens {
|
||||
@@ -79,7 +150,7 @@ impl Tokens {
|
||||
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
|
||||
header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||
header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
||||
header_sel_text: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
header_sel_text: Color32::from_rgb(0x0B, 0x5E, 0x31),
|
||||
header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3),
|
||||
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C),
|
||||
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
@@ -99,6 +170,18 @@ impl Tokens {
|
||||
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
|
||||
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
|
||||
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
|
||||
on_accent: Color32::WHITE,
|
||||
cat: [rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)],
|
||||
paper: Color32::WHITE,
|
||||
ref_colors: SHEET_REF_COLORS,
|
||||
sheet_ref_colors: SHEET_REF_COLORS,
|
||||
syntax: LIGHT_SYNTAX,
|
||||
warning: rgb(0xF2C32E),
|
||||
on_warning: rgb(0x1F1F1F),
|
||||
sheet_control_bg: SHEET_CONTROL_BG,
|
||||
sheet_control_border: SHEET_CONTROL_BORDER,
|
||||
sheet_control_text: SHEET_CONTROL_TEXT,
|
||||
sheet_track: SHEET_TRACK,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +207,7 @@ impl Tokens {
|
||||
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
|
||||
header_line: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||
header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||
header_sel_text: Color32::from_rgb(0x5C, 0xC5, 0x87),
|
||||
header_sel_text: Color32::from_rgb(0x6F, 0xD3, 0x9A),
|
||||
header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E),
|
||||
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
|
||||
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
@@ -136,7 +219,7 @@ impl Tokens {
|
||||
cell_text: Color32::BLACK,
|
||||
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||
shadow: Color32::from_black_alpha(90),
|
||||
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
|
||||
danger: Color32::from_rgb(0xFF, 0x95, 0x85),
|
||||
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
|
||||
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
|
||||
warning_ink: Color32::BLACK,
|
||||
@@ -144,12 +227,50 @@ impl Tokens {
|
||||
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
|
||||
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
|
||||
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
|
||||
on_accent: rgb(0x0F2A1A),
|
||||
cat: [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],
|
||||
paper: rgb(0x454545),
|
||||
ref_colors: [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],
|
||||
sheet_ref_colors: SHEET_REF_COLORS,
|
||||
syntax: DARK_SYNTAX,
|
||||
warning: rgb(0xF2C32E),
|
||||
on_warning: rgb(0x1F1F1F),
|
||||
sheet_control_bg: SHEET_CONTROL_BG,
|
||||
sheet_control_border: SHEET_CONTROL_BORDER,
|
||||
sheet_control_text: SHEET_CONTROL_TEXT,
|
||||
sheet_track: SHEET_TRACK,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }
|
||||
}
|
||||
|
||||
/// One colour of the category palette.
|
||||
pub fn cat(&self, c: Cat) -> Color32 {
|
||||
self.cat.get(c as usize).copied().unwrap_or(self.text)
|
||||
}
|
||||
|
||||
/// The reference colour for a formula's `index`-th reference, on the bar or on the sheet.
|
||||
pub fn ref_color(&self, index: usize, on_sheet: bool) -> Color32 {
|
||||
let set = if on_sheet { &self.sheet_ref_colors } else { &self.ref_colors };
|
||||
set.get(index % set.len()).copied().unwrap_or(self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// WCAG 2 relative luminance of an opaque colour.
|
||||
pub fn luminance(c: Color32) -> f32 {
|
||||
let lin = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
|
||||
}
|
||||
|
||||
/// WCAG 2 contrast ratio between two opaque colours (1 to 21).
|
||||
pub fn contrast(a: Color32, b: Color32) -> f32 {
|
||||
let (la, lb) = (luminance(a), luminance(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
pub const UI: &str = "ui";
|
||||
@@ -891,6 +1012,104 @@ mod font_binding_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod contrast_tests {
|
||||
use super::*;
|
||||
|
||||
/// Body text needs 4.5:1 (WCAG AA); glyphs, icons and other graphics need 3:1.
|
||||
const TEXT: f32 = 4.5;
|
||||
const GLYPH: f32 = 3.0;
|
||||
|
||||
/// Every foreground/background pair the chrome draws: ribbon, popups, dialogs, panes, formula
|
||||
/// bar, status bar, plus the colours that sit on the white sheet.
|
||||
fn pairs(t: &Tokens) -> Vec<(String, Color32, Color32, f32)> {
|
||||
let mut v: Vec<(String, Color32, Color32, f32)> = Vec::new();
|
||||
let mut add = |name: &str, fg: Color32, bg: Color32, min: f32| v.push((name.to_string(), fg, bg, min));
|
||||
let surfaces = [
|
||||
("window", t.window),
|
||||
("ribbon", t.ribbon),
|
||||
("menu", t.menu_bg),
|
||||
("input", t.input_bg),
|
||||
("status bar", t.status_bar),
|
||||
("tab bar", t.tab_bar),
|
||||
("active tab", t.tab_active),
|
||||
("hover", t.hover),
|
||||
];
|
||||
for (name, bg) in surfaces {
|
||||
add(&format!("text on {name}"), t.text, bg, TEXT);
|
||||
add(&format!("dim text on {name}"), t.text_dim, bg, TEXT);
|
||||
add(&format!("accent glyph on {name}"), t.accent, bg, GLYPH);
|
||||
add(&format!("danger on {name}"), t.danger, bg, TEXT);
|
||||
}
|
||||
add("text on pressed", t.text, t.pressed, TEXT);
|
||||
add("accent text (File, contextual tabs) on window", t.accent, t.window, TEXT);
|
||||
for (name, bg) in [("ribbon", t.ribbon), ("menu", t.menu_bg), ("hover", t.hover), ("pressed", t.pressed)] {
|
||||
for c in Cat::ALL {
|
||||
add(&format!("{c:?} icon colour on {name}"), t.cat(c), bg, GLYPH);
|
||||
}
|
||||
}
|
||||
add("icon stroke on paper", t.text, t.paper, GLYPH);
|
||||
add("on_accent on accent", t.on_accent, t.accent, TEXT);
|
||||
add("warning glyph", t.on_warning, t.warning, TEXT);
|
||||
add("notice text (warning)", t.text, t.notice_warn_bg, TEXT);
|
||||
add("notice text (info)", t.text, t.notice_info_bg, TEXT);
|
||||
add("header text", t.header_text, t.header_bg, TEXT);
|
||||
add("selected header text", t.header_sel_text, t.header_sel_bg, TEXT);
|
||||
for (i, c) in t.ref_colors.iter().enumerate() {
|
||||
add(&format!("formula bar reference {i}"), *c, t.input_bg, TEXT);
|
||||
}
|
||||
let s = t.syntax;
|
||||
for (name, c) in [("keyword", s.keyword), ("string", s.string), ("number", s.number), ("comment", s.comment), ("builtin", s.builtin)] {
|
||||
add(&format!("code {name} on input"), c, t.input_bg, TEXT);
|
||||
}
|
||||
// On the sheet.
|
||||
add("cell text", t.cell_text, t.grid_bg, TEXT);
|
||||
for (i, c) in t.sheet_ref_colors.iter().enumerate() {
|
||||
add(&format!("sheet reference {i}"), *c, t.grid_bg, TEXT);
|
||||
}
|
||||
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
|
||||
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
|
||||
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_pairs_meet_wcag_contrast_in_both_themes() {
|
||||
let mut failures = Vec::new();
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
for (name, fg, bg, min) in pairs(&t) {
|
||||
let ratio = contrast(fg, bg);
|
||||
if ratio < min {
|
||||
failures.push(format!("{} theme: {name} is {ratio:.2}:1, needs {min}:1", if t.dark { "dark" } else { "light" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "contrast failures:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_matches_wcag_reference_values() {
|
||||
assert!((contrast(Color32::BLACK, Color32::WHITE) - 21.0).abs() < 0.01);
|
||||
assert!((contrast(Color32::WHITE, Color32::WHITE) - 1.0).abs() < 0.001);
|
||||
// #767676 on white is the classic 4.54:1 grey.
|
||||
assert!((contrast(rgb(0x767676), Color32::WHITE) - 4.54).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_tokens_follow_the_sheet_not_the_theme() {
|
||||
let (l, d) = (Tokens::light(), Tokens::dark());
|
||||
assert_eq!(l.grid_bg, d.grid_bg);
|
||||
assert_eq!(l.sheet_ref_colors, d.sheet_ref_colors);
|
||||
assert_eq!(
|
||||
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
|
||||
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
|
||||
);
|
||||
for c in Cat::ALL {
|
||||
assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
use crate::SheetApp;
|
||||
pub use crate::code_editor::{Lang, code_editor};
|
||||
use crate::i18n;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::theme::{self, Cat, Tokens};
|
||||
|
||||
/// Translates a label or tooltip for the language of the frame being drawn (English if untranslated).
|
||||
/// Ribbon chrome all flows through these widgets, so translating here localises it in one place.
|
||||
@@ -79,13 +80,8 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response {
|
||||
/// Large ribbon button: 32px icon over a one- or two-line label.
|
||||
pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(11.5);
|
||||
let label = tr(ui, label);
|
||||
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
|
||||
let arrow_size = 10.0;
|
||||
let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 };
|
||||
let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max);
|
||||
let w = (text_w + 12.0).max(44.0);
|
||||
let (lines, w) = big_label(ui, label, dropdown);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click());
|
||||
if resp.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(rect, 5.0, t.pressed);
|
||||
@@ -93,6 +89,59 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
|
||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
||||
paint_big_label(ui, rect, &lines, dropdown);
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// Large split button (Formulas › AutoSum): the icon runs the default action, the label and its
|
||||
/// arrow open the menu. Returns the (main, arrow) responses; their accessible names are the label
|
||||
/// and "<label> options".
|
||||
pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = tr(ui, label);
|
||||
let (lines, w) = big_label(ui, label, true);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
|
||||
let split = rect.top() + 38.0;
|
||||
let main_rect = Rect::from_min_max(rect.min, pos2(rect.right(), split));
|
||||
let arrow_rect = Rect::from_min_max(pos2(rect.left(), split), rect.max);
|
||||
let main = ui.interact(main_rect, ui.id().with((label, "big-split-main")), Sense::click());
|
||||
let arrow = ui.interact(arrow_rect, ui.id().with((label, "big-split-arrow")), Sense::click());
|
||||
if main.hovered() || arrow.hovered() {
|
||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
||||
ui.painter().line_segment([pos2(rect.left() + 4.0, split), pos2(rect.right() - 4.0, split)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
for (part, r) in [(&main, main_rect), (&arrow, arrow_rect)] {
|
||||
if part.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(r, 5.0, t.pressed);
|
||||
}
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
||||
paint_big_label(ui, rect, &lines, true);
|
||||
let name = label.replace('\n', " ");
|
||||
let enabled = ui.is_enabled();
|
||||
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, format!("{name} options")));
|
||||
let tip = tip_text(ui, tip);
|
||||
(main.on_hover_text(tip.clone()), arrow.on_hover_text(tip))
|
||||
}
|
||||
|
||||
/// Size of the dropdown arrow beside a big button's last label line.
|
||||
const BIG_ARROW: f32 = 10.0;
|
||||
|
||||
/// A big button's label lines, laid out, and the width the button needs for them.
|
||||
fn big_label(ui: &Ui, label: &str, dropdown: bool) -> (Vec<std::sync::Arc<egui::Galley>>, f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(11.5);
|
||||
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
|
||||
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
|
||||
let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max);
|
||||
(lines, (text_w + 12.0).max(44.0))
|
||||
}
|
||||
|
||||
/// Paints a big button's label lines in `rect`, with the dropdown arrow beside the last one.
|
||||
fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>], dropdown: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let has_arrow = dropdown && i + 1 == lines.len();
|
||||
let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 };
|
||||
@@ -102,13 +151,12 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
|
||||
if has_arrow {
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)),
|
||||
Rect::from_center_size(pos2(left + line_w - BIG_ARROW / 2.0, y + 1.0), vec2(BIG_ARROW, BIG_ARROW)),
|
||||
Icon::Chevron,
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
}
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// Small ribbon button: 16px icon with optional label to the right.
|
||||
@@ -160,6 +208,34 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option<Color32>, tip: &str)
|
||||
(m.on_hover_text(tip_text(ui, tip)).clicked(), a)
|
||||
}
|
||||
|
||||
/// A small ribbon split button with a label (Error Checking): the icon and label run the default
|
||||
/// action, the arrow at the right opens a menu. Returns (main response, arrow response).
|
||||
pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(12.5);
|
||||
let label = tr(ui, label);
|
||||
let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover());
|
||||
let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height()));
|
||||
let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max);
|
||||
let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click());
|
||||
let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click());
|
||||
if m.hovered() || a.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
for (part, r) in [(&m, main), (&a, arrow)] {
|
||||
if part.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(r, 4.0, t.pressed);
|
||||
}
|
||||
}
|
||||
ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text);
|
||||
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(arrow.center().x, rect.center().y + 1.0), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim);
|
||||
let tip = tip_text(ui, tip);
|
||||
(m.on_hover_text(tip.clone()), a.on_hover_text(tip))
|
||||
}
|
||||
|
||||
/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is
|
||||
/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit).
|
||||
pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f32>) -> Option<String> {
|
||||
@@ -240,6 +316,7 @@ pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &s
|
||||
}
|
||||
|
||||
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut picked = None;
|
||||
const ROWS: usize = 7; // 6 tint rows + 1 standard row
|
||||
const COLS: usize = 10;
|
||||
@@ -333,9 +410,9 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
||||
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 1.0, col32);
|
||||
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
if resp.hovered() || cur == Some((row, col)) {
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(theme_hex(base, *tint));
|
||||
@@ -355,7 +432,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 1.0, c);
|
||||
if resp.hovered() || cur == Some((ROWS - 1, col)) {
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(hex.to_string());
|
||||
@@ -442,8 +519,9 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
ui.set_width(380.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 16.0, Color32::from_rgb(0xF2, 0xC3, 0x2E));
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().circle_filled(r.center(), 16.0, t.warning);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), t.on_warning);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(title).strong());
|
||||
ui.add(egui::Label::new(msg).wrap());
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Formulas › Error Checking is a split button, as in Excel: the button opens the Error Checking
|
||||
//! dialog, and its arrow opens a menu whose Circular References submenu lists a cell on each
|
||||
//! circular reference and selects the one picked, on its own sheet.
|
||||
|
||||
use egui::{Event, Shape, pos2, vec2};
|
||||
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
/// The submenu item, with the arrow egui puts after a submenu's label.
|
||||
const CIRCULAR: &str = "Circular References ⏵";
|
||||
|
||||
fn harness(loops: bool) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Q1 Data"})).unwrap();
|
||||
if loops {
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": "=B4"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B4", "input": "=B3"})).unwrap();
|
||||
}
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
if loops {
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap();
|
||||
}
|
||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().ui.ribbon_tab = "Formulas".into();
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// The painted "Error Checking" ribbon label: its left edge, right edge and vertical center.
|
||||
fn label(h: &Harness) -> (f32, f32, f32) {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == "Error Checking" => {
|
||||
Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the Formulas tab shows Error Checking")
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, at: egui::Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn open_menu(h: &mut Harness) {
|
||||
let (_, right, y) = label(h);
|
||||
// The arrow sits right of the label, past the 6 px gap after the text.
|
||||
click(h, pos2(right + 13.0, y));
|
||||
}
|
||||
|
||||
fn hover(h: &mut Harness, label: &str) {
|
||||
let at = h.get_by_label(label).rect().center();
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.run_steps(6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_checking_button_opens_the_dialog_and_its_arrow_opens_the_menu() {
|
||||
let mut h = harness(false);
|
||||
let (left, _, y) = label(&h);
|
||||
click(&mut h, pos2(left + 4.0, y));
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the button opens Error Checking");
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
open_menu(&mut h);
|
||||
assert!(h.state().dialog.is_none(), "the arrow opens a menu, not the dialog");
|
||||
h.get_by_label("Error Checking…").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the menu's first item opens Error Checking");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_references_is_unavailable_without_a_circular_reference() {
|
||||
let mut h = harness(false);
|
||||
open_menu(&mut h);
|
||||
assert!(h.get_by_label(CIRCULAR).accesskit_node().is_disabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_references_lists_the_loops_and_selects_the_cell_picked() {
|
||||
let mut h = harness(true);
|
||||
open_menu(&mut h);
|
||||
assert!(!h.get_by_label(CIRCULAR).accesskit_node().is_disabled());
|
||||
hover(&mut h, CIRCULAR);
|
||||
assert!(h.query_by_label("Sheet1!C2").is_some(), "the loop on the active sheet is listed");
|
||||
// A sheet name with a space is quoted, as in a formula.
|
||||
let other = ["'Q1 Data'!B3", "'Q1 Data'!B4"].into_iter().find(|l| h.query_by_label(l).is_some()).expect("the loop on Q1 Data is listed");
|
||||
h.get_by_label(other).click();
|
||||
h.run_steps(4);
|
||||
let app = h.state();
|
||||
let d = app.session.active().unwrap();
|
||||
assert_eq!(d.wb.active_sheet, 1, "picking a cell on Q1 Data goes to Q1 Data");
|
||||
assert_eq!(format!("'Q1 Data'!{}", d.selection.active.a1()), other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_references_scrolls_to_the_cell_picked() {
|
||||
let mut h = harness(false);
|
||||
h.state_mut().run("cell.set", json!({"cell": "C500", "input": "=C500+1"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().view().scroll.y, 0.0, "setup: the view is at the top of the sheet");
|
||||
open_menu(&mut h);
|
||||
hover(&mut h, CIRCULAR);
|
||||
h.get_by_label("Sheet1!C500").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500");
|
||||
assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_references_scrolls_a_list_taller_than_the_window() {
|
||||
let mut h = harness(false);
|
||||
for row in 1..=100 {
|
||||
h.state_mut().run("cell.set", json!({"cell": format!("A{row}"), "input": format!("=A{row}+1")})).unwrap();
|
||||
}
|
||||
h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap();
|
||||
h.run_steps(4);
|
||||
open_menu(&mut h);
|
||||
hover(&mut h, CIRCULAR);
|
||||
let window = h.ctx.content_rect();
|
||||
assert!(h.get_by_label("Sheet1!A100").rect().bottom() > window.bottom(), "setup: the last cell starts below the window");
|
||||
hover(&mut h, "Sheet1!A1");
|
||||
for _ in 0..20 {
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, -10.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(4);
|
||||
}
|
||||
let last = h.get_by_label("Sheet1!A100").rect();
|
||||
assert!(last.bottom() <= window.bottom(), "the wheel brings the last cell into the window: {last:?} in {window:?}");
|
||||
click(&mut h, last.center());
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill
|
||||
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…).
|
||||
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…, and from
|
||||
//! a date Fill Days, Fill Weekdays, Fill Months and Fill Years).
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -203,6 +204,34 @@ fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() {
|
||||
assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_adds_fill_days_weekdays_months_and_years() {
|
||||
let mut h = harness(|s| {
|
||||
// Friday 1/30/2026 is serial 46052.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/30/2026"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
});
|
||||
drag_handle(&mut h, "B1", "B3", PointerButton::Secondary);
|
||||
for label in ["Fill Days", "Fill Months", "Fill Years"] {
|
||||
assert!(h.query_by_label(label).is_some(), "{label}");
|
||||
}
|
||||
h.get_by_label("Fill Weekdays").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(values(&h, &["B2", "B3"]), [n(46055.0), n(46056.0)], "Monday 2/2 and Tuesday 2/3");
|
||||
assert!(h.state().grid.fill_menu.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_gets_no_date_fills() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
|
||||
assert!(h.query_by_label("Fill Series").is_some());
|
||||
for label in ["Fill Days", "Fill Weekdays", "Fill Months", "Fill Years"] {
|
||||
assert!(h.query_by_label(label).is_none(), "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_mid_right_drag_cancels_it() {
|
||||
let mut h = harness(|s| {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells,
|
||||
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403).
|
||||
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403), and from a date as Fill
|
||||
//! Days, Fill Weekdays, Fill Months or Fill Years.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -139,6 +140,33 @@ fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() {
|
||||
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_months_redoes_a_date_drag_by_months() {
|
||||
let mut h = harness(|s| {
|
||||
// 1/31/2026 is serial 46053.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
});
|
||||
drag_handle(&mut h, "B1", "B3", PointerButton::Primary);
|
||||
assert_eq!(values(&h, &["B2", "B3"]), [n(46054.0), n(46055.0)], "a plain drag steps by days");
|
||||
h.get_by_label("Auto Fill Options").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Fill Months").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(values(&h, &["B2", "B3"]), [n(46081.0), n(46112.0)], "Feb 28 and Mar 31");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_drag_offers_no_date_fills() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
h.get_by_label("Auto Fill Options").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Copy Cells").is_some());
|
||||
assert!(h.query_by_label("Fill Days").is_none());
|
||||
}
|
||||
|
||||
fn formatted(s: &mut Session) {
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! The auto-growing formula bar, the 5-line in-cell editor and the code editor widget.
|
||||
|
||||
use egui::{Event, Key, Modifiers, Rect, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::formula_bar::{BarLayout, MAX_LINES, one_line};
|
||||
use gridcraft_ui_egui::widgets::{Lang, code_editor};
|
||||
use gridcraft_ui_egui::{SheetApp, theme};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
fn blank() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
/// Long enough for the 120 ms height ease to finish.
|
||||
fn settle(h: &mut Harness) {
|
||||
h.run_steps(16);
|
||||
}
|
||||
|
||||
fn key(key: Key, modifiers: Modifiers) -> [Event; 2] {
|
||||
[
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn alt_enter(h: &mut Harness) {
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::ALT));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn layout(h: &Harness) -> BarLayout {
|
||||
BarLayout::get(&h.ctx)
|
||||
}
|
||||
|
||||
fn row_height(h: &Harness) -> f32 {
|
||||
h.ctx.fonts_mut(|f| f.row_height(&theme::ui_font(13.0)))
|
||||
}
|
||||
|
||||
fn same(a: Rect, b: Rect) -> bool {
|
||||
(a.min - b.min).length() < 0.5 && (a.max - b.max).length() < 0.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_grows_to_five_lines_then_scrolls_with_name_box_pinned() {
|
||||
let mut h = harness(blank());
|
||||
// The one-line height is 30 px unless the font's rows need more (Windows' do).
|
||||
let rh = row_height(&h);
|
||||
let base = one_line(rh);
|
||||
let one = layout(&h);
|
||||
assert!((one.height - base).abs() < 0.5, "one line is {base} px, got {}", one.height);
|
||||
assert_eq!(one.lines, 1);
|
||||
h.state_mut().begin_edit(None, true);
|
||||
h.run_steps(2);
|
||||
type_text(&mut h, "line 1");
|
||||
for n in 2..=8 {
|
||||
alt_enter(&mut h);
|
||||
type_text(&mut h, &format!("line {n}"));
|
||||
settle(&mut h);
|
||||
let l = layout(&h);
|
||||
assert_eq!(l.lines, n, "the editor holds {n} lines");
|
||||
let expect = base + (n.min(MAX_LINES) - 1) as f32 * rh;
|
||||
assert!((l.height - expect).abs() < 0.5, "{n} lines: height {} expected {expect}", l.height);
|
||||
assert!(same(l.name_box, one.name_box), "the Name Box stays on the first line ({n} lines)");
|
||||
assert!(same(l.buttons, one.buttons), "the buttons stay on the first line ({n} lines)");
|
||||
let caret = l.caret.expect("a caret while editing");
|
||||
assert!(l.field.contains_rect(caret.shrink(0.5)), "the caret stays visible at {n} lines: {caret:?} in {:?}", l.field);
|
||||
}
|
||||
assert_eq!(layout(&h).visible_lines, MAX_LINES);
|
||||
// Enter commits the multi-line text; viewing the cell keeps the bar at five lines.
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
settle(&mut h);
|
||||
let text = (1..=8).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::from(text.as_str()));
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
settle(&mut h);
|
||||
let viewing = layout(&h);
|
||||
assert_eq!(viewing.lines, 8);
|
||||
assert!((viewing.height - (base + 4.0 * rh)).abs() < 0.5);
|
||||
// An empty cell shrinks it back to one line.
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
settle(&mut h);
|
||||
assert!((layout(&h).height - base).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_that_closes_a_message_box_adds_no_line_to_the_entry() {
|
||||
// A message box over the editor (a data validation alert) takes Enter to close. The bar's
|
||||
// field is multi-line, and a field of its own would add a line on that Enter; the editor
|
||||
// handles Enter itself, so the field does nothing with it.
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().begin_edit(None, true);
|
||||
h.run_steps(2);
|
||||
type_text(&mut h, "abc");
|
||||
h.state_mut().message = Some(("Stop".into(), "Whole numbers only.".into()));
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().message.is_none(), "Enter closed the message box");
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("abc"), "and added nothing to the entry");
|
||||
assert_eq!(layout(&h).lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chevron_pins_the_bar_at_five_lines() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().ui.formula_bar_expanded = true;
|
||||
settle(&mut h);
|
||||
let rh = row_height(&h);
|
||||
let base = one_line(rh);
|
||||
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
|
||||
h.state_mut().ui.formula_bar_expanded = false;
|
||||
settle(&mut h);
|
||||
assert!((layout(&h).height - base).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_eases_instead_of_jumping() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().ui.formula_bar_expanded = true;
|
||||
h.run_steps(2);
|
||||
let mid = layout(&h).height;
|
||||
let rh = row_height(&h);
|
||||
let base = one_line(rh);
|
||||
assert!(mid > base + 0.5 && mid < base + 4.0 * rh - 0.5, "mid-ease height {mid}");
|
||||
settle(&mut h);
|
||||
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
|
||||
}
|
||||
|
||||
fn in_cell_height(lines: usize) -> f32 {
|
||||
let mut s = blank();
|
||||
s.execute("selection.set", json!({"cell": "B3"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
let text = (1..=lines).map(|n| format!("row {n}")).collect::<Vec<_>>().join("\n");
|
||||
h.state_mut().begin_edit(Some(text.clone()), false);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, text, "the text is kept whole");
|
||||
let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::new("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted");
|
||||
rect.height()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_cell_editor_grows_with_every_line() {
|
||||
// The formula bar stops at five lines and scrolls; the in-cell editor shows every line, as
|
||||
// the grid's own tests ask (`an_unwrapped_multiline_edit_shows_every_line`).
|
||||
let (four, five, nine) = (in_cell_height(4), in_cell_height(5), in_cell_height(9));
|
||||
assert!(five > four + 1.0, "grows with the lines ({four} then {five})");
|
||||
assert!(nine > five + 1.0, "and past five ({five} then {nine})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_editor_indents_and_keeps_indentation() {
|
||||
struct State {
|
||||
text: String,
|
||||
focused: bool,
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
// egui's own fonts stand in for the system ones under the theme's family names.
|
||||
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");
|
||||
if !s.focused {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
s.focused = true;
|
||||
}
|
||||
code_editor(ui, id, &mut s.text, Lang::Python, 5);
|
||||
},
|
||||
State { text: String::new(), focused: false },
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Text("if x:".into()));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::Text("y = 1".into()));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Tab, Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::Text("z".into()));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().text, "if x:\n y = 1\n z");
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar.
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
/// Ctrl+Shift on Windows and Linux sets `ctrl`, `command` and `shift`.
|
||||
const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: true };
|
||||
/// Control+Shift on a Mac: `ctrl` without `command`.
|
||||
const MAC_CONTROL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: false };
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn ctrl_shift_u(h: &mut Harness, m: Modifiers) {
|
||||
h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: true, repeat: false, modifiers: m });
|
||||
h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: false, repeat: false, modifiers: m });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn expanded(h: &Harness) -> bool {
|
||||
h.state().ui.formula_bar_expanded
|
||||
}
|
||||
|
||||
fn underlined(h: &Harness) -> bool {
|
||||
h.state().session.journal.iter().any(|(id, _)| id == "home.underline")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_u_on_the_grid_expands_and_collapses_the_formula_bar() {
|
||||
let mut h = harness();
|
||||
assert!(!expanded(&h));
|
||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
||||
assert!(expanded(&h), "the first Ctrl+Shift+U expands the formula bar");
|
||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
||||
assert!(!expanded(&h), "the second Ctrl+Shift+U collapses it");
|
||||
assert!(!underlined(&h), "Ctrl+Shift+U is not Underline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_shift_u_expands_the_formula_bar_in_excel_for_mac() {
|
||||
let mut h = harness();
|
||||
h.ctx.set_os(egui::os::OperatingSystem::Mac);
|
||||
h.run_steps(2);
|
||||
ctrl_shift_u(&mut h, MAC_CONTROL_SHIFT);
|
||||
assert!(expanded(&h));
|
||||
assert!(h.state().editor.is_none(), "Control+Shift+U does not start editing as Control+U does");
|
||||
let expand = Some(Shortcut::Run("view.expandFormulaBar", json!({})));
|
||||
assert_eq!(lookup(Key::U, Some(Key::U), MAC_CONTROL_SHIFT, true), expand);
|
||||
assert_eq!(lookup(Key::U, Some(Key::U), CTRL_SHIFT, false), expand);
|
||||
// Control+U on a Mac still edits the active cell.
|
||||
assert_eq!(lookup(Key::U, Some(Key::U), Modifiers::CTRL, true), Some(Shortcut::Edit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_u_while_editing_keeps_the_entry() {
|
||||
let mut h = harness();
|
||||
h.state_mut().begin_edit(Some("draft".into()), false);
|
||||
h.run_steps(2);
|
||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
||||
assert!(expanded(&h), "Ctrl+Shift+U works in the cell editor too");
|
||||
let ed = h.state().editor.as_ref().expect("the edit stays open");
|
||||
assert_eq!(ed.text, "draft");
|
||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
||||
assert!(!expanded(&h));
|
||||
assert!(h.state().editor.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_formula_bar_command_sets_or_toggles_the_state() {
|
||||
let mut h = harness();
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true})));
|
||||
assert!(expanded(&h));
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true})));
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false})));
|
||||
assert!(!expanded(&h));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The Go To dialog lists the named ranges of every sheet and the references entered before; a
|
||||
//! double-click on an entry goes there.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
|
||||
session.execute("selection.set", json!({"sheet": 0})).unwrap();
|
||||
for (name, refers, scope) in [("Price", "=Sheet1!$B$2:$B$5", "Workbook"), ("Local", "=Sheet2!$C$3", "Sheet2"), ("Rate", "=0.07", "Workbook")] {
|
||||
session.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn control(h: &mut Harness, method: &str, params: Json) -> Json {
|
||||
let ctx = h.ctx.clone();
|
||||
let out = match handle(h.state_mut(), &ctx, method, ¶ms) {
|
||||
Outcome::Done(v) => v,
|
||||
Outcome::Screenshot { .. } => json!(null),
|
||||
};
|
||||
h.run_steps(2);
|
||||
out
|
||||
}
|
||||
|
||||
fn button(h: &mut Harness, pos: Pos2, pressed: bool) {
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn double_click(h: &mut Harness, label: &str) {
|
||||
let pos = h.get_by_label(label).rect().center();
|
||||
button(h, pos, true);
|
||||
button(h, pos, false);
|
||||
button(h, pos, true);
|
||||
button(h, pos, false);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn place(h: &Harness) -> (usize, String) {
|
||||
let d = h.state().session.active().unwrap();
|
||||
(d.wb.active_sheet, d.selection.a1())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_name_scoped_to_another_sheet_goes_there() {
|
||||
let mut h = harness();
|
||||
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
|
||||
assert!(h.query_by_label("Price").is_some(), "a workbook range name is listed");
|
||||
assert!(h.query_by_label("Rate").is_none(), "a constant is not a named range");
|
||||
double_click(&mut h, "Sheet2!Local");
|
||||
assert!(h.state().dialog.is_none(), "the dialog closed");
|
||||
assert_eq!(place(&h), (1, "C3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_entered_is_listed_and_a_double_click_returns_to_it() {
|
||||
let mut h = harness();
|
||||
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "reference", "value": "B14:C22,F19:G30"}));
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none(), "Enter went");
|
||||
assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string()));
|
||||
|
||||
control(&mut h, "selection.set", json!({"sheet": 1}));
|
||||
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
|
||||
double_click(&mut h, "Sheet1!B14:C22,Sheet1!F19:G30");
|
||||
assert!(h.state().dialog.is_none(), "the dialog closed");
|
||||
assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string()));
|
||||
}
|
||||
@@ -387,6 +387,30 @@ fn inserted_chart_is_selected_so_delete_removes_it() {
|
||||
assert_eq!(value(&h, "A1"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_column_or_bar_menu_inserts_a_hundred_percent_stacked_bar() {
|
||||
// Insert › Column or Bar (keytip N, C1) lists the column and bar types of the All Charts list.
|
||||
let mut s = blank();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:C3"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
key(&mut h, Key::F10, Modifiers::NONE);
|
||||
for (k, t) in [(Key::N, "n"), (Key::C, "c"), (Key::Num1, "1")] {
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::Text(t.into()));
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.run_steps(2);
|
||||
for label in ["Clustered Column", "Stacked Column", "100% Stacked Column", "Clustered Bar", "Stacked Bar", "100% Stacked Bar"] {
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, label).is_some(), "the menu lists {label}");
|
||||
}
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "100% Stacked Bar").click();
|
||||
h.run_steps(2);
|
||||
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::BarStacked100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_every_ribbon_tab_without_panicking() {
|
||||
let mut s = Session::new();
|
||||
@@ -558,6 +582,33 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() {
|
||||
assert!(h.state().message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_charts_inserts_a_hundred_percent_stacked_area() {
|
||||
let mut s = blank();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:C3"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().open_dialog("recommendedCharts", json!({"tab": "All Charts"}));
|
||||
h.run_steps(4);
|
||||
// The entry sits below the list's visible height: scroll the list with the wheel first.
|
||||
let first = h.get_by_label("Pie").rect().center();
|
||||
h.input_mut().events.push(Event::PointerMoved(first));
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -10.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(20);
|
||||
h.get_by_label("100% Stacked Area").click();
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::AreaStacked100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_charts_command_without_a_type_opens_the_dialog() {
|
||||
// #1399: the ribbon button and the NR keytip ran the command directly and inserted the first
|
||||
@@ -922,6 +973,43 @@ fn status_bar_shows_macro_badge() {
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_names_a_circular_reference_by_its_address() {
|
||||
let mut s = blank();
|
||||
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
|
||||
let h = harness(s);
|
||||
assert!(h.query_by_label("Circular References: B2").is_some(), "the status bar names the cell on the active sheet without its sheet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_names_no_cell_when_every_circular_reference_is_on_another_sheet() {
|
||||
let mut s = blank();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
assert!(h.query_by_label("Circular References").is_some(), "the status bar still reports circular references");
|
||||
assert!(h.query_by_label("Circular References: B2").is_none(), "no cell address from another sheet");
|
||||
h.state_mut().session.execute("sheet.activate", json!({"sheet": "Data"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Circular References: B2").is_some(), "the active sheet's loop is named");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_names_a_circular_reference_on_the_active_sheet() {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=A1+1"})).unwrap();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
let circular = s.active().unwrap().calc.circular.clone();
|
||||
assert_eq!(circular, vec![(0, CellRef::new(0, 0)), (1, CellRef::new(1, 1))], "Sheet1's loop is listed first");
|
||||
let h = harness(s);
|
||||
assert!(h.query_by_label("Circular References: B2").is_some(), "the status bar names the loop on the active sheet");
|
||||
assert!(h.query_by_label("Circular References: A1").is_none(), "not the first loop listed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_f8_opens_the_macros_dialog() {
|
||||
let mut h = harness(blank());
|
||||
|
||||
@@ -1783,10 +1783,7 @@ fn keytip_ms_() {
|
||||
fn keytip_msp_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "MSP", false);
|
||||
assert!(h.state().dialog.is_none(), "MSP must not open a dialog");
|
||||
assert!(h.state().message.is_none(), "MSP must not open a message box");
|
||||
assert!(h.state().toast.is_none(), "MSP must not raise a toast");
|
||||
assert!(h.state().session.journal.is_empty(), "MSP must not journal");
|
||||
assert!(h.state().dialog.is_some(), "MSP must open the pasteName dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,6 +24,7 @@ mod dialog_confirm;
|
||||
mod dialog_focus;
|
||||
mod dialog_move;
|
||||
mod drag_cursor;
|
||||
mod error_checking_menu;
|
||||
mod file_menu;
|
||||
mod fill_handle_double_click;
|
||||
mod fill_handle_right_drag;
|
||||
@@ -34,10 +35,13 @@ mod find_dialog_focus;
|
||||
mod floating_pictures;
|
||||
mod font_size_box;
|
||||
mod format_painter;
|
||||
mod formula_bar;
|
||||
mod formula_bar_expand;
|
||||
mod formula_locale;
|
||||
mod formula_point_headers;
|
||||
mod formula_popup;
|
||||
mod freeze_handles;
|
||||
mod go_to_dialog;
|
||||
mod header_autofit;
|
||||
mod header_column_drag;
|
||||
mod host_chrome;
|
||||
@@ -59,11 +63,14 @@ mod open_encrypted;
|
||||
mod outline;
|
||||
mod page_layout_view;
|
||||
mod page_setup_margins;
|
||||
mod paste_names;
|
||||
mod paste_special_dialog;
|
||||
mod pivot_pane;
|
||||
mod pivot_show_details;
|
||||
mod pivot_show_details_menu;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod ribbon_layout;
|
||||
mod right_to_left;
|
||||
mod scroll_position;
|
||||
mod scrollbar_column_fill;
|
||||
@@ -77,8 +84,10 @@ mod sheet_tabs;
|
||||
mod show_objects;
|
||||
mod shrink_to_fit;
|
||||
mod sort_dialog_columns;
|
||||
mod status_bar_menu;
|
||||
mod symbol_glyphs;
|
||||
mod system_theme;
|
||||
mod tab_split;
|
||||
mod table_headings;
|
||||
mod text_box_editing;
|
||||
mod text_overflow_scroll;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The outline band of grouped rows and columns: − / + and level buttons (#373).
|
||||
//! The outline band of grouped rows and columns: − / + and level buttons (#373), and the
|
||||
//! Outline Settings dialog that moves the summary rows above their detail.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
@@ -90,3 +91,30 @@ fn outline_buttons_collapse_expand_and_undo() {
|
||||
fn outline_buttons_work_on_right_to_left_sheets() {
|
||||
outline_buttons_collapse_and_expand(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_dialog_puts_summary_rows_above_their_detail() {
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
let mut h = harness();
|
||||
h.state_mut().session.run("data.group", json!({"rows": "5:8"})).unwrap();
|
||||
h.state_mut().open_dialog("outlineSettings", json!({}));
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Summary rows below detail").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("OK").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
assert!(!sh.summary_below && sh.summary_right);
|
||||
|
||||
// The − now sits on row 4, above the detail; it hides rows 5:8 and marks row 4 collapsed.
|
||||
let minus = button(&h, toggle(true, 1));
|
||||
let geo = h.state().grid.geo.clone().unwrap();
|
||||
let row4 = geo.cell_rect(&sh, gridcraft_engine::core::CellRef::new(3, 0));
|
||||
assert!(row4.y_range().contains(minus.y), "{minus:?} is not on row 4 ({row4:?})");
|
||||
click(&mut h, minus);
|
||||
assert_eq!(hidden_rows(&h), [4, 5, 6, 7]);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.rows.get(&3).is_some_and(|l| l.collapsed));
|
||||
assert!(!sh.rows.get(&8).is_some_and(|l| l.collapsed));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! transform, and headers are edited in place, committed on a click elsewhere, and undoable.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_engine::model::SheetViewMode;
|
||||
@@ -163,6 +164,27 @@ fn the_first_page_edits_its_own_header_when_different_first_page_is_on() {
|
||||
assert_eq!(pr.header, "&LLeft", "the other pages keep their header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_with_document_and_align_with_page_margins_are_check_boxes_on_the_header_and_footer_tab() {
|
||||
let mut h = fixture();
|
||||
gridcraft_ui_egui::paper_view::start_editing(h.state_mut());
|
||||
h.run_steps(4);
|
||||
let options = |h: &Harness| {
|
||||
let pr = &h.state().session.doc().unwrap().wb.active().unwrap().print;
|
||||
(pr.hf_scale_with_doc, pr.hf_align_with_margins)
|
||||
};
|
||||
assert_eq!(options(&h), (true, true));
|
||||
h.get_by_label("Scale with Document").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(options(&h), (false, true));
|
||||
h.get_by_label("Align with Page Margins").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(options(&h), (false, false));
|
||||
assert!(h.state().grid.hf_edit.is_some(), "the header stays in edit");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(options(&h), (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_header_and_footer_switches_to_page_layout_view() {
|
||||
let mut h = fixture();
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! F3 opens the Paste Name dialog when the workbook has names: OK puts the selected name into the
|
||||
//! formula being edited, and Paste List writes every name and what it refers to, as in Excel.
|
||||
|
||||
use egui::{Event, Key, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(names: bool) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
if names {
|
||||
session.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
|
||||
session.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.07"})).unwrap();
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn dialog(h: &Harness) -> Option<&str> {
|
||||
h.state().dialog.as_ref().map(|d| d.name())
|
||||
}
|
||||
|
||||
fn editor_text(h: &Harness) -> Option<String> {
|
||||
h.state().editor.as_ref().map(|e| e.text.clone())
|
||||
}
|
||||
|
||||
fn value(h: &Harness, cell: &str) -> Value {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, key: Key) {
|
||||
h.key_press(key);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f3_opens_paste_name_only_when_the_workbook_has_names() {
|
||||
let mut h = harness(false);
|
||||
press(&mut h, Key::F3);
|
||||
assert_eq!(dialog(&h), None, "no names: F3 does nothing");
|
||||
let mut h = harness(true);
|
||||
press(&mut h, Key::F3);
|
||||
assert_eq!(dialog(&h), Some("pasteName"));
|
||||
// The names are listed in alphabetical order.
|
||||
let rate = h.get_by_label("Rate").rect().top();
|
||||
let sales = h.get_by_label("Sales").rect().top();
|
||||
assert!(rate < sales, "Rate is listed above Sales");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_starts_a_formula_with_the_selected_name() {
|
||||
let mut h = harness(true);
|
||||
press(&mut h, Key::F3);
|
||||
h.get_by_label("Sales").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=Sales"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_channel_confirms_the_dialog_like_ok() {
|
||||
let mut h = harness(true);
|
||||
let ctx = h.ctx.clone();
|
||||
for (method, params) in
|
||||
[("ui.dialog", json!({"name": "pasteName"})), ("ui.dialog.set", json!({"field": "name", "value": "Sales"})), ("ui.dialog.confirm", json!({}))]
|
||||
{
|
||||
match handle(h.state_mut(), &ctx, method, ¶ms) {
|
||||
Outcome::Done(v) => assert_eq!(v["ok"], json!(true), "{method}: {v}"),
|
||||
Outcome::Screenshot { .. } => panic!("{method} took a screenshot"),
|
||||
}
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=Sales"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_choose_the_name_and_leave_the_active_cell() {
|
||||
let mut h = harness(true);
|
||||
press(&mut h, Key::F3);
|
||||
press(&mut h, Key::ArrowDown);
|
||||
press(&mut h, Key::Enter);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=Sales"), "Down moved from Rate to Sales");
|
||||
let active = h.state().session.active().unwrap().selection.active;
|
||||
assert_eq!(active, CellRef::new(0, 0), "the arrow went to the dialog, not the grid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_editing_the_name_goes_in_at_the_caret_and_the_edit_goes_on() {
|
||||
let mut h = harness(true);
|
||||
h.state_mut().begin_edit(Some("=SUM(".into()), false);
|
||||
h.run_steps(4);
|
||||
press(&mut h, Key::F3);
|
||||
assert_eq!(dialog(&h), Some("pasteName"), "F3 in the cell editor opens the dialog");
|
||||
// While the dialog is open, typed text and arrow keys do not change the formula being edited.
|
||||
h.input_mut().events.push(Event::Text("x".into()));
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::ArrowLeft);
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=SUM("), "the formula is unchanged while the dialog is open");
|
||||
h.get_by_label("Sales").click();
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::Enter);
|
||||
assert_eq!(dialog(&h), None, "Enter is the dialog's OK");
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=SUM(Sales"), "the cell is still being edited");
|
||||
assert_eq!(value(&h, "A1"), Value::Empty, "nothing was entered yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_leaves_the_selected_text_selected() {
|
||||
let mut h = harness(true);
|
||||
h.state_mut().begin_edit(Some("=SUM(A1".into()), false);
|
||||
// Edit mode (F2): the arrow keys move the caret instead of leaving the cell.
|
||||
if let Some(ed) = h.state_mut().editor.as_mut() {
|
||||
ed.enter_mode = false;
|
||||
}
|
||||
h.run_steps(4);
|
||||
for _ in 0..2 {
|
||||
h.key_press_modifiers(egui::Modifiers::SHIFT, Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
}
|
||||
press(&mut h, Key::F3);
|
||||
assert_eq!(dialog(&h), Some("pasteName"));
|
||||
h.get_by_label(" Cancel ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
h.input_mut().events.push(Event::Text("B2".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(editor_text(&h).as_deref(), Some("=SUM(B2"), "typing replaces the text selected before the dialog opened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_list_writes_the_names_from_the_active_cell() {
|
||||
let mut h = harness(true);
|
||||
h.state_mut().run("selection.set", json!({"range": "C2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::F3);
|
||||
h.get_by_label("Paste List").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(value(&h, "C2"), Value::text("Rate"));
|
||||
assert_eq!(value(&h, "D2"), Value::text("=0.07"));
|
||||
assert_eq!(value(&h, "C3"), Value::text("Sales"));
|
||||
assert_eq!(value(&h, "D3"), Value::text("=Sheet1!$A$1:$A$3"));
|
||||
assert_eq!(editor_text(&h), None);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's
|
||||
//! Show Details); a label of the report still opens the cell for editing.
|
||||
//! Show Details) unless its Enable show details option is off; a label of the report still opens
|
||||
//! the cell for editing.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
@@ -104,3 +105,17 @@ fn double_click_in_an_out_of_date_report_asks_for_a_refresh() {
|
||||
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message);
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_with_enable_show_details_off_says_why_and_does_not_edit() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("pivot.options", json!({"pivot": "PivotTable1", "enableShowDetails": false})).unwrap();
|
||||
h.run_steps(2);
|
||||
let pos = center(&h, "E4");
|
||||
double_click(&mut h, pos);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.editor.is_none(), "the value cell must not open for editing");
|
||||
assert!(app.message.as_ref().is_some_and(|(_, m)| m == "Show Details is turned off for this PivotTable."), "{:?}", app.message);
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Right-clicking a PivotTable's value cell offers Show Details in the cell menu (Excel's
|
||||
//! right-click › Show Details); labels of the report and ordinary cells don't offer it.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap();
|
||||
// Report at D3: D4 East, E4 its Sum of Sales, E6 the Grand Total.
|
||||
session
|
||||
.execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]}))
|
||||
.unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center()
|
||||
}
|
||||
|
||||
fn right_click(h: &mut egui_kittest::Harness<'_, SheetApp>, cell: &str) {
|
||||
let pos = center(h, cell);
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(h.state().grid.context_menu.is_some(), "the cell menu opens on {cell}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_in_the_cell_menu_lists_a_value_cells_records_on_a_new_sheet() {
|
||||
let mut h = harness();
|
||||
right_click(&mut h, "E4");
|
||||
h.get_by_label("Show Details").click();
|
||||
h.run_steps(2);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.grid.context_menu.is_none(), "the menu closes");
|
||||
let wb = &app.session.active().unwrap().wb;
|
||||
assert_eq!(wb.sheets.len(), 2);
|
||||
let sheet = wb.active().unwrap();
|
||||
let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display();
|
||||
assert_eq!(
|
||||
[text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")],
|
||||
["Region", "Sales", "East", "100", "East", "30", ""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_in_the_cell_menu_on_the_grand_total_takes_every_record() {
|
||||
let mut h = harness();
|
||||
right_click(&mut h, "E6");
|
||||
h.get_by_label("Show Details").click();
|
||||
h.run_steps(2);
|
||||
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
assert_eq!(wb.sheets.len(), 2);
|
||||
let sheet = wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::parse("B4").unwrap()).display(), "30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_of_the_report_and_ordinary_cells_have_no_show_details_item() {
|
||||
let mut h = harness();
|
||||
for cell in ["D4", "D3", "A2"] {
|
||||
right_click(&mut h, cell);
|
||||
assert!(h.query_by_label("Sort A to Z").is_some(), "the cell menu is up on {cell}");
|
||||
assert!(h.query_by_label("Show Details").is_none(), "{cell} offers no Show Details");
|
||||
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
h.input_mut().events.push(escape);
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_cell_offers_show_details_while_it_is_turned_off() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("pivot.options", json!({"enableShowDetails": false, "pivot": "PivotTable1"})).unwrap();
|
||||
h.run_steps(2);
|
||||
for cell in ["E4", "E6"] {
|
||||
right_click(&mut h, cell);
|
||||
assert!(h.query_by_label("Sort A to Z").is_some(), "the cell menu is up on {cell}");
|
||||
assert!(h.query_by_label("Show Details").is_none(), "{cell} offers no Show Details");
|
||||
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
h.input_mut().events.push(escape);
|
||||
h.run_steps(2);
|
||||
}
|
||||
// Turned back on, the value cell offers it again.
|
||||
h.state_mut().session.execute("pivot.options", json!({"enableShowDetails": true, "pivot": "PivotTable1"})).unwrap();
|
||||
h.run_steps(2);
|
||||
right_click(&mut h, "E4");
|
||||
assert!(h.query_by_label("Show Details").is_some(), "E4 offers Show Details");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cell_menu_computes_the_report_once_for_all_its_cells() {
|
||||
let mut h = harness();
|
||||
right_click(&mut h, "E4");
|
||||
assert!(h.query_by_label("Show Details").is_some());
|
||||
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
h.input_mut().events.push(escape);
|
||||
h.run_steps(2);
|
||||
// West goes away behind the session's back, so the revision stays the same. A menu that
|
||||
// computed the report again for D4 would find it out of date and offer Show Details there.
|
||||
let doc = h.state_mut().session.active_mut().unwrap();
|
||||
let si = doc.wb.active_sheet;
|
||||
std::sync::Arc::make_mut(&mut doc.wb).sheet_mut(si).unwrap().set_value(CellRef::parse("A3").unwrap(), Value::text("East"));
|
||||
right_click(&mut h, "D4");
|
||||
assert!(h.query_by_label("Show Details").is_none(), "D4 is a label of the report computed for E4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_in_an_out_of_date_report_asks_for_a_refresh() {
|
||||
let mut h = harness();
|
||||
// West goes away without a refresh: the report on the sheet is no longer what the source gives.
|
||||
h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap();
|
||||
h.run_steps(2);
|
||||
right_click(&mut h, "E4");
|
||||
h.get_by_label("Show Details").click();
|
||||
h.run_steps(2);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message);
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Ribbon layout: the big split button, the scroll arrows of an overflowing tab, and every tab
|
||||
//! fitting the card's height.
|
||||
|
||||
use egui::{FontDefinitions, FontFamily, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::icons::Icon;
|
||||
use gridcraft_ui_egui::ribbon::{RibbonOverflow, TABS};
|
||||
use gridcraft_ui_egui::{SheetApp, theme, widgets};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn app_at(width: f32, tab: &str) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.ui.ribbon_tab = tab.into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(width, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tab_fits_the_card_height() {
|
||||
// Columns of small buttons hold three rows; a fourth would be cut off at the card's bottom.
|
||||
for tab in TABS {
|
||||
let h = app_at(1440.0, tab);
|
||||
let o = RibbonOverflow::get(&h.ctx);
|
||||
assert!(o.visible_height > 0.0, "{tab}: the card was laid out");
|
||||
assert!(o.content_height <= o.visible_height + 0.5, "{tab}: {} px of groups in {} px", o.content_height, o.visible_height);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overflowing_tab_shows_scroll_arrows_that_scroll() {
|
||||
let mut h = app_at(700.0, "Home");
|
||||
let o = RibbonOverflow::get(&h.ctx);
|
||||
assert!(o.overflows(), "Home is wider than a 700 px window");
|
||||
assert_eq!(o.offset, 0.0);
|
||||
assert!(h.query_by_label("Scroll ribbon left").is_none(), "nothing to the left yet");
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
let scrolled = RibbonOverflow::get(&h.ctx).offset;
|
||||
assert!(scrolled > 0.0);
|
||||
h.get_by_label("Scroll ribbon left").click();
|
||||
h.run_steps(3);
|
||||
assert!(RibbonOverflow::get(&h.ctx).offset < scrolled);
|
||||
// A tab that fits shows no arrows.
|
||||
let h = app_at(1440.0, "Automate");
|
||||
assert!(!RibbonOverflow::get(&h.ctx).overflows());
|
||||
assert!(h.query_by_label("Scroll ribbon right").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tab_keeps_a_scroll_offset_of_its_own() {
|
||||
// Scrolling Home must not hide the first groups of another overflowing tab, and Home keeps
|
||||
// its offset while the other tab is used.
|
||||
let mut h = app_at(700.0, "Home");
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
let home_offset = RibbonOverflow::get(&h.ctx).offset;
|
||||
assert!(home_offset > 0.0);
|
||||
let other = TABS.iter().copied().find(|t| *t != "Home" && RibbonOverflow::get(&app_at(700.0, t).ctx).overflows());
|
||||
let other = other.expect("another tab overflows a 700 px window");
|
||||
h.state_mut().ui.ribbon_tab = other.into();
|
||||
h.run_steps(3);
|
||||
let o = RibbonOverflow::get(&h.ctx);
|
||||
assert!(o.overflows(), "{other} overflows");
|
||||
assert_eq!(o.offset, 0.0, "{other} starts at its left edge");
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
assert!(RibbonOverflow::get(&h.ctx).offset > 0.0);
|
||||
h.state_mut().ui.ribbon_tab = "Home".into();
|
||||
h.run_steps(3);
|
||||
assert_eq!(RibbonOverflow::get(&h.ctx).offset, home_offset, "Home kept its offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scroll_arrows_stay_inside_a_narrow_window() {
|
||||
// The tab row can be wider than a narrow window (more so with wider system fonts); the card
|
||||
// and its arrows stay inside the window, and the right arrow still scrolls.
|
||||
for width in [600.0, 480.0] {
|
||||
let mut h = app_at(width, "Home");
|
||||
let arrow = h.get_by_label("Scroll ribbon right").rect();
|
||||
assert!(arrow.max.x <= width, "{width}: the arrow ends at {}", arrow.max.x);
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
assert!(RibbonOverflow::get(&h.ctx).offset > 0.0, "{width}: the right arrow scrolls");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clicks {
|
||||
main: u32,
|
||||
arrow: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_split_button_separates_the_default_action_from_the_menu() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(
|
||||
|ui, c: &mut Clicks| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
// egui's own font stands in for the system UI font; it applies from the next pass.
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
fonts.families.insert(FontFamily::Name(theme::UI.into()), proportional.clone());
|
||||
fonts.families.insert(FontFamily::Name(theme::CELL.into()), proportional);
|
||||
ui.ctx().set_fonts(fonts);
|
||||
return;
|
||||
}
|
||||
let (main, arrow) = widgets::big_split_button(ui, Icon::Sum, "Auto\nSum", "AutoSum");
|
||||
c.main += u32::from(main.clicked());
|
||||
c.arrow += u32::from(arrow.clicked());
|
||||
},
|
||||
Clicks::default(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto Sum").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto Sum options").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto Sum options").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().main, h.state().arrow), (1, 2));
|
||||
// The tooltip carries the shortcut hint, as every ribbon button's does.
|
||||
h.get_by_label("Auto Sum").hover();
|
||||
h.run_steps(3);
|
||||
let hint = if cfg!(target_os = "macos") { "AutoSum (\u{2318}" } else { "AutoSum (Alt+=" };
|
||||
assert!(h.query(egui_kittest::kittest::By::new().label_contains(hint)).is_some(), "tooltip with the shortcut");
|
||||
// The icon part sits above the label part, across the full width.
|
||||
let main = h.get_by_label("Auto Sum").rect();
|
||||
let arrow = h.get_by_label("Auto Sum options").rect();
|
||||
assert!(main.max.y <= arrow.min.y + 0.5);
|
||||
assert!((main.min.x - arrow.min.x).abs() < 0.5 && (main.max.x - arrow.max.x).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_autosum_runs_on_the_icon_and_opens_its_menu_on_the_label() {
|
||||
let mut h = app_at(1600.0, "Formulas");
|
||||
h.get_by_label("AutoSum").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the icon runs AutoSum");
|
||||
assert!(h.query_by_label("AVERAGE").is_none());
|
||||
h.get_by_label("AutoSum options").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("AVERAGE").click();
|
||||
h.run_steps(2);
|
||||
let last = h.state().session.journal.iter().rev().find(|(id, _)| id == "formulas.autoSum").map(|(_, p)| p.clone());
|
||||
assert_eq!(last.and_then(|p| p.get("function").and_then(|f| f.as_str().map(str::to_string))).as_deref(), Some("AVERAGE"));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Right-clicking the status bar opens a menu that turns its selection statistics on and off.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "9")] {
|
||||
session.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
session.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness, pos: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicking_the_status_bar_turns_on_minimum() {
|
||||
let mut h = harness();
|
||||
assert!(h.query_by_label("Sum: 12").is_some(), "Sum is on by default");
|
||||
assert!(h.query_by_label("Minimum: -1").is_none(), "Minimum is off by default");
|
||||
// An empty stretch of the 26-point status bar, between the mode text and the statistics.
|
||||
right_click(&mut h, pos2(300.0, 787.0));
|
||||
h.get_by_label("Minimum").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().session.prefs.status_bar.minimum);
|
||||
assert!(h.query_by_label("Minimum: -1").is_some(), "the status bar shows the minimum of the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicking_the_status_bar_text_opens_the_menu() {
|
||||
for text in ["Sum: 12", "Ready"] {
|
||||
let mut h = harness();
|
||||
let pos = h.get_by_label(text).rect().center();
|
||||
right_click(&mut h, pos);
|
||||
assert!(h.query_by_label("Numerical Count").is_some(), "a right-click on {text:?} opens the statistics menu");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The handle between the sheet tabs and the horizontal scrollbar: dragging it gives the tabs more
|
||||
//! or less of the tab bar, as in Excel.
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, Shape, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(width: f32, sheets: usize) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap();
|
||||
for i in 1..sheets {
|
||||
s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap();
|
||||
}
|
||||
s.execute("sheet.activate", json!({"sheet":0})).unwrap();
|
||||
let mut fonts_ready = false;
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state(
|
||||
move |ui, app: &mut SheetApp| {
|
||||
if !fonts_ready {
|
||||
ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions());
|
||||
fonts_ready = true;
|
||||
return;
|
||||
}
|
||||
app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0)));
|
||||
gridcraft_ui_egui::tabs::sheet_tabs(app, ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(20);
|
||||
h
|
||||
}
|
||||
|
||||
fn split(h: &Harness) -> Rect {
|
||||
h.state().grid.tab_split.expect("the tab bar has a split handle")
|
||||
}
|
||||
|
||||
fn track(h: &Harness) -> Rect {
|
||||
h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar")
|
||||
}
|
||||
|
||||
/// The clip rectangle of the tab strip: the viewport the tab names are painted in.
|
||||
fn strip(h: &Harness) -> Rect {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == "Tab00" => Some(s.clip_rect),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Tab00 was painted")
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(from),
|
||||
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_handle_right_gives_overflowing_tabs_more_room() {
|
||||
let mut h = harness(1200.0, 30);
|
||||
let (before, handle) = (strip(&h), split(&h));
|
||||
assert!(before.width() <= 1200.0 * 0.6, "the tabs start at their default share: {before:?}");
|
||||
drag(&mut h, handle.center(), pos2(1000.0, handle.center().y));
|
||||
let after = strip(&h);
|
||||
assert!(after.width() > before.width() + 200.0, "the strip grew: {before:?} -> {after:?}");
|
||||
assert!((split(&h).center().x - 1000.0).abs() <= 2.0, "the handle follows the pointer: {:?}", split(&h));
|
||||
assert_eq!(track(&h).left(), split(&h).right(), "the scrollbar starts at the handle");
|
||||
assert!(track(&h).width() >= 100.0, "the scrollbar keeps a usable width: {:?}", track(&h));
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 30, "dragging the handle does not edit the workbook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_handle_left_gives_the_scrollbar_more_room() {
|
||||
let mut h = harness(1200.0, 30);
|
||||
let (before, handle) = (track(&h), split(&h));
|
||||
drag(&mut h, handle.center(), pos2(handle.center().x - 300.0, handle.center().y));
|
||||
let after = track(&h);
|
||||
assert!(after.width() > before.width() + 280.0, "the scrollbar grew: {before:?} -> {after:?}");
|
||||
assert!(strip(&h).width() < 1200.0 * 0.6 - 280.0, "the strip shrank: {:?}", strip(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handle_stops_before_the_scrollbar_gets_too_narrow_and_keeps_new_sheet_shown() {
|
||||
let mut h = harness(1200.0, 30);
|
||||
let handle = split(&h);
|
||||
drag(&mut h, handle.center(), pos2(1195.0, handle.center().y));
|
||||
assert!(track(&h).width() >= 100.0 - 0.5, "the scrollbar keeps 100 px: {:?}", track(&h));
|
||||
let (handle, bar_left) = (split(&h), strip(&h).left());
|
||||
drag(&mut h, handle.center(), pos2(0.0, handle.center().y));
|
||||
// The tab strip has no width left, and the New sheet button (26 px after a 6 px gap) still
|
||||
// fits before the handle.
|
||||
assert!((split(&h).left() - bar_left - 32.0).abs() <= 1.0, "the handle is 32 px after the strip's start {bar_left}: {:?}", split(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_narrower_window_keeps_the_scrollbar_after_the_handle_was_dragged_right() {
|
||||
let mut h = harness(1200.0, 30);
|
||||
let handle = split(&h);
|
||||
drag(&mut h, handle.center(), pos2(1195.0, handle.center().y));
|
||||
h.set_size(vec2(700.0, 120.0));
|
||||
h.run_steps(20);
|
||||
let track = track(&h);
|
||||
assert!(track.width() >= 100.0 - 0.5 && track.right() <= 700.5, "the scrollbar keeps 100 px in the bar: {track:?}");
|
||||
assert_eq!(track.left(), split(&h).right(), "the scrollbar starts at the handle");
|
||||
assert!(strip(&h).right() <= split(&h).left() - 32.0 + 0.5, "the tabs end before New sheet: {:?}", strip(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_the_tabs_do_not_fill_stays_empty_and_new_sheet_follows_the_last_tab() {
|
||||
let mut h = harness(1200.0, 2);
|
||||
let plus_before = track(&h).left() - 8.0 - 26.0;
|
||||
let handle = split(&h);
|
||||
drag(&mut h, handle.center(), pos2(900.0, handle.center().y));
|
||||
assert!((split(&h).center().x - 900.0).abs() <= 2.0, "the handle stays where it was dragged: {:?}", split(&h));
|
||||
assert_eq!(track(&h).left(), split(&h).right());
|
||||
// New sheet (+) is still right after the last tab: clicking there adds a sheet.
|
||||
let at = pos2(plus_before + 13.0, handle.center().y);
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(20);
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 3, "New sheet stayed after the last tab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handle_shows_the_resize_cursor() {
|
||||
let mut h = harness(1200.0, 30);
|
||||
let at = split(&h).center();
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_workbook_keeps_its_own_tab_share() {
|
||||
let mut h = harness(1200.0, 30);
|
||||
let handle = split(&h);
|
||||
drag(&mut h, handle.center(), pos2(1000.0, handle.center().y));
|
||||
assert!(track(&h).left() > 990.0, "the first workbook's scrollbar starts at the handle: {:?}", track(&h));
|
||||
h.state_mut().session.new_workbook();
|
||||
h.run_steps(20);
|
||||
// The new workbook's one tab takes its default room, so its scrollbar starts right after New sheet.
|
||||
assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h));
|
||||
assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share");
|
||||
}
|
||||
@@ -40,7 +40,9 @@ fn group_kind(g: &El) -> Option<ChartKind> {
|
||||
"pieChart" | "pie3DChart" | "ofPieChart" => ChartKind::Pie,
|
||||
"doughnutChart" => ChartKind::Doughnut,
|
||||
"areaChart" | "area3DChart" => {
|
||||
if stacked || pct {
|
||||
if pct {
|
||||
ChartKind::AreaStacked100
|
||||
} else if stacked {
|
||||
ChartKind::AreaStacked
|
||||
} else {
|
||||
ChartKind::Area
|
||||
@@ -284,7 +286,7 @@ enum G {
|
||||
Line { stacked: bool, markers: bool },
|
||||
Pie,
|
||||
Doughnut,
|
||||
Area { stacked: bool },
|
||||
Area { grouping: &'static str },
|
||||
Scatter { lines: bool },
|
||||
Bubble,
|
||||
Radar,
|
||||
@@ -303,8 +305,9 @@ fn group_for(k: ChartKind) -> G {
|
||||
ChartKind::LineStacked => G::Line { stacked: true, markers: false },
|
||||
ChartKind::Pie => G::Pie,
|
||||
ChartKind::Doughnut => G::Doughnut,
|
||||
ChartKind::Area => G::Area { stacked: false },
|
||||
ChartKind::AreaStacked => G::Area { stacked: true },
|
||||
ChartKind::Area => G::Area { grouping: "standard" },
|
||||
ChartKind::AreaStacked => G::Area { grouping: "stacked" },
|
||||
ChartKind::AreaStacked100 => G::Area { grouping: "percentStacked" },
|
||||
ChartKind::Scatter => G::Scatter { lines: false },
|
||||
ChartKind::ScatterLines => G::Scatter { lines: true },
|
||||
ChartKind::Bubble => G::Bubble,
|
||||
@@ -671,10 +674,7 @@ fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb
|
||||
G::Doughnut => {
|
||||
format!("<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/><c:holeSize val=\"50\"/></c:doughnutChart>")
|
||||
}
|
||||
G::Area { stacked } => format!(
|
||||
"<c:areaChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>",
|
||||
if stacked { "stacked" } else { "standard" }
|
||||
),
|
||||
G::Area { grouping } => format!("<c:areaChart><c:grouping val=\"{grouping}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>"),
|
||||
G::Scatter { .. } => format!("<c:scatterChart><c:scatterStyle val=\"lineMarker\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:scatterChart>"),
|
||||
G::Bubble => {
|
||||
format!("<c:bubbleChart><c:varyColors val=\"0\"/>{sers}{dl}<c:bubbleScale val=\"100\"/><c:showNegBubbles val=\"0\"/>{ax}</c:bubbleChart>")
|
||||
|
||||
@@ -74,14 +74,24 @@ impl<'a> Package<'a> {
|
||||
let remaining = MAX_TOTAL.saturating_sub(self.total);
|
||||
let limit = maximum.min(remaining);
|
||||
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
(&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
|
||||
let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
|
||||
// A read that fails partway has already spent decompression work and buffered data
|
||||
// the package budget never sees: count the allowance as spent, so a loop over many
|
||||
// broken parts can't decompress it over and over.
|
||||
self.total = MAX_TOTAL;
|
||||
IoError::Zip(format!("{part}: {e}"))
|
||||
});
|
||||
if out.len() as u64 > limit {
|
||||
// The allowance was spent getting here; make the exhaustion terminal instead of
|
||||
// letting later reads pay the same decompression cost again.
|
||||
self.total = MAX_TOTAL;
|
||||
return Err(IoError::TooLarge(if remaining < maximum {
|
||||
"package expands to more than 1 GB".into()
|
||||
} else {
|
||||
format!("{part} expands to more than {maximum} bytes")
|
||||
}));
|
||||
}
|
||||
read?;
|
||||
self.total += out.len() as u64;
|
||||
Ok(Some(out))
|
||||
}
|
||||
@@ -201,4 +211,20 @@ mod tests {
|
||||
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
|
||||
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
|
||||
}
|
||||
|
||||
/// An over-budget read must spend the package's allowance once and for all: loops over many
|
||||
/// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
|
||||
/// remaining allowance again and again.
|
||||
#[test]
|
||||
fn a_failed_part_ends_the_package_budget() {
|
||||
let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
|
||||
let mut pkg = Package::open(&z).unwrap();
|
||||
assert!(pkg.read("a.xml").unwrap().is_some());
|
||||
// Nothing left to spend within the per-part limit either.
|
||||
pkg.total = MAX_TOTAL - 1;
|
||||
assert!(pkg.read("b.xml").is_err());
|
||||
assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
|
||||
// Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
|
||||
assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,6 +818,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
if !pt.show_headers {
|
||||
s.push_str(" showHeaders=\"0\"");
|
||||
}
|
||||
if !pt.enable_show_details {
|
||||
s.push_str(" enableDrill=\"0\"");
|
||||
}
|
||||
// `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"),
|
||||
// `colGrandTotals` the grand total column at its right ("On for Rows").
|
||||
if !pt.grand_totals_cols {
|
||||
@@ -1282,6 +1285,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
|
||||
pt.grand_totals_rows = x.flag("colGrandTotals", true);
|
||||
pt.grand_totals_cols = x.flag("rowGrandTotals", true);
|
||||
pt.show_headers = x.flag("showHeaders", true);
|
||||
pt.enable_show_details = x.flag("enableDrill", true);
|
||||
pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string();
|
||||
if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() {
|
||||
cx.warn(format!("PivotTable \"{}\": calculated items and label/value filters are not supported", pt.name));
|
||||
|
||||
@@ -33,12 +33,62 @@ struct RowState {
|
||||
/// Dynamic-array spill ranges and legacy array ranges: cached values inside them (other than
|
||||
/// the anchor) are not stored, so the formula can fill them again.
|
||||
dynamic: Vec<RangeRef>,
|
||||
/// Cells inside the ranges above, for a cap before a pathological sheet proves it itself.
|
||||
dynamic_cells: u64,
|
||||
/// Row-membership checks served so far, for the work cap above.
|
||||
dynamic_checks: u64,
|
||||
dropped: u64,
|
||||
rows: u64,
|
||||
}
|
||||
|
||||
/// Whether `pos` lies in a known dynamic range that isn't its anchor: the cell's cached value
|
||||
/// gives way to the formula that will fill it. Charges every scanned range against one work
|
||||
/// budget, then gives up consulting the ranges at all: the worst kind of file may then import
|
||||
/// a few cached spill values, but it never stalls the whole import.
|
||||
fn in_dynamic(st: &mut RowState, pos: CellRef) -> bool {
|
||||
if st.dynamic_checks > MAX_DYNAMIC_CHECKS || st.dynamic.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let ranges = st.dynamic.len();
|
||||
let hit = st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos);
|
||||
st.dynamic_checks = st.dynamic_checks.saturating_add(ranges as u64);
|
||||
hit
|
||||
}
|
||||
|
||||
/// Records a dynamic-array range, so its cells outside the anchor import as empty and the
|
||||
/// formula fills them again. Repeated identical anchors (a damaged or hostile sheet may repeat
|
||||
/// the same record) are recorded once, and count and area are capped: every later cell inside
|
||||
/// known range territory pays a scan proportional to what is recorded here.
|
||||
fn push_dynamic(st: &mut RowState, range: RangeRef) {
|
||||
if st.dynamic.contains(&range) {
|
||||
return;
|
||||
}
|
||||
let cells = range.count();
|
||||
if st.dynamic.len() >= MAX_DYNAMIC_RANGES || st.dynamic_cells.saturating_add(cells) > MAX_DYNAMIC_CELLS {
|
||||
return;
|
||||
}
|
||||
st.dynamic_cells = st.dynamic_cells.saturating_add(cells);
|
||||
st.dynamic.push(range);
|
||||
}
|
||||
|
||||
const MAX_HYPERLINK_CELLS: u64 = 10_000;
|
||||
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
|
||||
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
|
||||
const MAX_HYPERLINK_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000;
|
||||
/// Most dynamic-array and legacy-array ranges of one sheet.
|
||||
const MAX_DYNAMIC_RANGES: usize = 65_536;
|
||||
/// Worst-case cells inside the dynamic ranges the sheet declares. Beyond it further ranges are
|
||||
/// dropped: they would cost their storage in every membership lookup that follows.
|
||||
const MAX_DYNAMIC_CELLS: u64 = 16_000_000;
|
||||
/// Most row-membership checks the sheet's ranges may serve. Beyond it the remaining cells are
|
||||
/// imported with their cached values instead of being blanked for a spill that may still come,
|
||||
/// so adversarial range counts can't make each cell scan a long list (a real workbook has a
|
||||
/// handful of ranges).
|
||||
const MAX_DYNAMIC_CHECKS: u64 = 16_000_000;
|
||||
/// Most conditional-format range cells one sheet keeps, over all rules of an element: every
|
||||
/// rule copies its element's whole range list, so rules × ranges must stay bounded.
|
||||
const MAX_COND_CELLS: u64 = 4_000_000;
|
||||
|
||||
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
|
||||
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
|
||||
@@ -70,6 +120,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
if let Some(t) = e.child("tabColor") {
|
||||
sheet.tab_color = Some(read_color(t, &cx.palette));
|
||||
}
|
||||
if let Some(o) = e.child("outlinePr") {
|
||||
sheet.summary_below = o.flag("summaryBelow", true);
|
||||
sheet.summary_right = o.flag("summaryRight", true);
|
||||
}
|
||||
fit_to_page = e.child("pageSetUpPr").is_some_and(|p| p.flag("fitToPage", false));
|
||||
}
|
||||
"sheetViews" => read_views(e, &mut sheet),
|
||||
@@ -461,7 +515,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
||||
}
|
||||
let dynamic = c.attr("cm").is_some();
|
||||
let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic));
|
||||
let value = if formula.is_none() && st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos) { Value::Empty } else { value };
|
||||
let value = if formula.is_none() && in_dynamic(st, pos) { Value::Empty } else { value };
|
||||
sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style });
|
||||
}
|
||||
|
||||
@@ -502,7 +556,7 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef,
|
||||
let mut fm = Formula::new(text);
|
||||
let range = f.attr("ref").and_then(RangeRef::parse).unwrap_or(RangeRef::cell(pos));
|
||||
if !range.is_single() {
|
||||
st.dynamic.push(range);
|
||||
push_dynamic(st, range);
|
||||
}
|
||||
if !dynamic {
|
||||
fm.array = Some(range);
|
||||
@@ -672,6 +726,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
||||
if let Some(id) = ext_id {
|
||||
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
|
||||
}
|
||||
let rules = sheet.cond_formats.len();
|
||||
let area = ranges.iter().map(RangeRef::count).sum::<u64>().saturating_mul(1 + rules as u64);
|
||||
if area > MAX_COND_CELLS {
|
||||
cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped"));
|
||||
break;
|
||||
}
|
||||
sheet.cond_formats.push(CondFormat {
|
||||
ranges: ranges.clone(),
|
||||
rule,
|
||||
@@ -734,6 +794,7 @@ fn read_validation(v: &El) -> Option<Validation> {
|
||||
|
||||
fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
|
||||
let mut cells = 0u64;
|
||||
let mut bytes = 0u64;
|
||||
for h in e.kids("hyperlink") {
|
||||
let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue };
|
||||
let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default();
|
||||
@@ -749,13 +810,19 @@ fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &m
|
||||
if target.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string), external };
|
||||
let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external };
|
||||
let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
|
||||
for c in range.iter() {
|
||||
if cells >= MAX_HYPERLINK_CELLS {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
|
||||
return;
|
||||
}
|
||||
if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
|
||||
return;
|
||||
}
|
||||
cells += 1;
|
||||
bytes += link_bytes;
|
||||
sheet.hyperlinks.insert(c, link.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +138,21 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
let mut s = format!("{XML_DECL}<worksheet xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
|
||||
// sheetPr
|
||||
let fit = sheet.print.fit_to.is_some();
|
||||
if sheet.tab_color.is_some() || fit {
|
||||
let summary = !sheet.summary_below || !sheet.summary_right;
|
||||
if sheet.tab_color.is_some() || fit || summary {
|
||||
s.push_str("<sheetPr>");
|
||||
if let Some(c) = &sheet.tab_color {
|
||||
let _ = write!(s, "<tabColor {}/>", color_attrs(c));
|
||||
}
|
||||
if summary {
|
||||
s.push_str("<outlinePr");
|
||||
for (key, on) in [("summaryBelow", sheet.summary_below), ("summaryRight", sheet.summary_right)] {
|
||||
if !on {
|
||||
let _ = write!(s, " {key}=\"0\"");
|
||||
}
|
||||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
if fit {
|
||||
s.push_str("<pageSetUpPr fitToPage=\"1\"/>");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user